diff --git a/docs/wiki/recursos-avancados/events-system.md b/docs/wiki/recursos-avancados/events-system.md index ef65300..81ac61d 100644 --- a/docs/wiki/recursos-avancados/events-system.md +++ b/docs/wiki/recursos-avancados/events-system.md @@ -148,11 +148,21 @@ Content-Type: application/json "message": { "conversation": "Olá!" }, + "referral": { + "ctwaClid": "FAKE_CLID_abc123xyz", + "sourceURL": "https://fb.me/fake-ad-link", + "sourceID": "123456789012345", + "sourceType": "ad", + "showAdAttribution": true, + "automatedGreetingMessageShown": true + }, "messageTimestamp": "1699999999" } } ``` +> **Nota**: quando a conversa vier de um anúncio Click-to-WhatsApp, o payload também inclui `data.referral` com os metadados brutos de `contextInfo.externalAdReply`, e o mesmo JSON é persistido no registro da mensagem. + ### Implementação no Servidor Receptor **Node.js (Express)**: diff --git a/go.mod b/go.mod index ef2a864..fa8b05b 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,8 @@ require ( google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/postgres v1.5.9 - gorm.io/gorm v1.25.10 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.30.0 modernc.org/sqlite v1.33.1 ) @@ -67,6 +68,7 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 4ec26f6..4a35e86 100644 --- a/go.sum +++ b/go.sum @@ -250,8 +250,12 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= diff --git a/pkg/message/model/message_model.go b/pkg/message/model/message_model.go index 3644ca5..dd4544c 100644 --- a/pkg/message/model/message_model.go +++ b/pkg/message/model/message_model.go @@ -1,16 +1,19 @@ package message_model import ( + "encoding/json" + "github.com/google/uuid" "gorm.io/gorm" ) type Message struct { - Id string `json:"id" gorm:"type:uuid;primaryKey"` - MessageID string `json:"message_id" gorm:"unique"` - Timestamp string `json:"timestamp"` - Status string `json:"status"` - Source string `json:"source"` + Id string `json:"id" gorm:"type:uuid;primaryKey"` + MessageID string `json:"message_id" gorm:"unique"` + Timestamp string `json:"timestamp"` + Status string `json:"status"` + Source string `json:"source"` + Referral json.RawMessage `json:"referral,omitempty" gorm:"type:jsonb"` } func (m *Message) BeforeCreate(tx *gorm.DB) (err error) { diff --git a/pkg/message/repository/message_repository.go b/pkg/message/repository/message_repository.go index 5a61ddb..66f2840 100644 --- a/pkg/message/repository/message_repository.go +++ b/pkg/message/repository/message_repository.go @@ -18,9 +18,14 @@ type messageRepository struct { } func (m *messageRepository) InsertMessage(message message_model.Message) error { + updates := []string{"timestamp", "status", "source"} + if len(message.Referral) > 0 { + updates = append(updates, "referral") + } + return m.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "message_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"timestamp", "status", "source"}), + DoUpdates: clause.AssignmentColumns(updates), }).Create(&message).Error } diff --git a/pkg/message/repository/message_repository_test.go b/pkg/message/repository/message_repository_test.go new file mode 100644 index 0000000..525b17e --- /dev/null +++ b/pkg/message/repository/message_repository_test.go @@ -0,0 +1,64 @@ +package message_repository + +import ( + "encoding/json" + "testing" + + message_model "github.com/EvolutionAPI/evolution-go/pkg/message/model" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestInsertMessagePreservesReferralOnStatusUpdate(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + + if err := db.AutoMigrate(&message_model.Message{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + repo := NewMessageRepository(db) + referral := json.RawMessage(`{"ctwaClid":"abc123","showAdAttribution":true}`) + + initial := message_model.Message{ + MessageID: "msg-1", + Timestamp: "2026-05-09 10:00:00", + Status: "Received", + Source: "1551999999999", + Referral: referral, + } + + if err := repo.InsertMessage(initial); err != nil { + t.Fatalf("insert initial message: %v", err) + } + + updated := message_model.Message{ + MessageID: "msg-1", + Timestamp: "2026-05-09 10:05:00", + Status: "Read", + Source: "1551999999999", + } + + if err := repo.InsertMessage(updated); err != nil { + t.Fatalf("insert updated message: %v", err) + } + + got, err := repo.GetMessageByID("msg-1") + if err != nil { + t.Fatalf("get message: %v", err) + } + + if got == nil { + t.Fatal("expected message, got nil") + } + + if got.Status != "Read" { + t.Fatalf("expected status Read, got %q", got.Status) + } + + if string(got.Referral) != string(referral) { + t.Fatalf("expected referral %s, got %s", referral, got.Referral) + } +} diff --git a/pkg/whatsmeow/service/referral.go b/pkg/whatsmeow/service/referral.go new file mode 100644 index 0000000..987d5d3 --- /dev/null +++ b/pkg/whatsmeow/service/referral.go @@ -0,0 +1,50 @@ +package whatsmeow_service + +import ( + "encoding/json" + + "go.mau.fi/whatsmeow/proto/waE2E" + "google.golang.org/protobuf/encoding/protojson" +) + +func extractReferralFromMessage(message *waE2E.Message) json.RawMessage { + contextInfo := getContextInfoFromMessage(message) + if contextInfo == nil || contextInfo.GetExternalAdReply() == nil { + return nil + } + + referral, err := protojson.Marshal(contextInfo.GetExternalAdReply()) + if err != nil || len(referral) == 0 { + return nil + } + + return json.RawMessage(referral) +} + +func getContextInfoFromMessage(message *waE2E.Message) *waE2E.ContextInfo { + if message == nil { + return nil + } + + if extendedText := message.GetExtendedTextMessage(); extendedText != nil { + return extendedText.GetContextInfo() + } + + if image := message.GetImageMessage(); image != nil { + return image.GetContextInfo() + } + + if audio := message.GetAudioMessage(); audio != nil { + return audio.GetContextInfo() + } + + if document := message.GetDocumentMessage(); document != nil { + return document.GetContextInfo() + } + + if video := message.GetVideoMessage(); video != nil { + return video.GetContextInfo() + } + + return nil +} diff --git a/pkg/whatsmeow/service/referral_test.go b/pkg/whatsmeow/service/referral_test.go new file mode 100644 index 0000000..5369614 --- /dev/null +++ b/pkg/whatsmeow/service/referral_test.go @@ -0,0 +1,66 @@ +package whatsmeow_service + +import ( + "encoding/json" + "testing" + + "go.mau.fi/whatsmeow/proto/waE2E" +) + +func TestExtractReferralFromMessage(t *testing.T) { + message := &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: stringPtr("Hello! I want to know more about this ad."), + ContextInfo: &waE2E.ContextInfo{ + ExternalAdReply: &waE2E.ContextInfo_ExternalAdReplyInfo{ + Title: stringPtr("Your Dream Farm"), + Body: stringPtr("Discover exclusive rural properties in the countryside."), + CtwaClid: stringPtr("FAKE_CLID_abc123xyz"), + Ref: stringPtr("landing_page_01"), + SourceApp: stringPtr("facebook"), + SourceType: stringPtr("ad"), + SourceID: stringPtr("123456789012345"), + SourceURL: stringPtr("https://fb.me/fake-ad-link"), + ShowAdAttribution: boolPtr(true), + ClickToWhatsappCall: boolPtr(true), + AutomatedGreetingMessageShown: boolPtr(true), + GreetingMessageBody: stringPtr("Hello! I want to know more about this ad."), + }, + }, + }, + } + + referral := extractReferralFromMessage(message) + if len(referral) == 0 { + t.Fatal("expected referral payload, got empty") + } + + var got map[string]any + if err := json.Unmarshal(referral, &got); err != nil { + t.Fatalf("unmarshal referral: %v", err) + } + + if got["ctwaClid"] != "FAKE_CLID_abc123xyz" { + t.Fatalf("expected ctwaClid to be preserved, got %#v", got["ctwaClid"]) + } + + if got["sourceURL"] != "https://fb.me/fake-ad-link" { + t.Fatalf("expected sourceURL to be preserved, got %#v", got["sourceURL"]) + } + + if got["showAdAttribution"] != true { + t.Fatalf("expected showAdAttribution to be preserved, got %#v", got["showAdAttribution"]) + } + + if got["automatedGreetingMessageShown"] != true { + t.Fatalf("expected automatedGreetingMessageShown to be preserved, got %#v", got["automatedGreetingMessageShown"]) + } +} + +func stringPtr(v string) *string { + return &v +} + +func boolPtr(v bool) *bool { + return &v +} diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 78ec6c1..3c13385 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -122,6 +122,18 @@ type MyClient struct { qrcodeCount int } +func (mycli *MyClient) persistMessageAsync(message message_model.Message) { + if mycli == nil || mycli.messageRepository == nil { + return + } + + go func() { + if err := mycli.messageRepository.InsertMessage(message); err != nil { + mycli.loggerWrapper.GetLogger(mycli.userID).LogError("[%s] Failed to persist message %s: %v", mycli.userID, message.MessageID, err) + } + }() +} + type ClientData struct { Instance *instance_model.Instance Subscriptions []string @@ -1161,6 +1173,8 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { dataMap = make(map[string]interface{}) } + referral := extractReferralFromMessage(evt.Message) + if evt.Message.GetPollUpdateMessage() != nil { fmt.Printf("[POLL DEBUG] 🎯 PollUpdateMessage detected!\n") fmt.Printf("[POLL DEBUG] � BEFORE accessing evt.Info - Sender: %s, Server: %s\n", evt.Info.Sender.String(), evt.Info.Sender.Server) @@ -1256,6 +1270,10 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { dataMap["isQuoted"] = true } + if len(referral) > 0 { + dataMap["referral"] = referral + } + if mycli.config.WebhookFiles { isMedia := false @@ -1514,6 +1532,18 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postMap["data"] = dataMap + if mycli.config.DatabaseSaveMessages { + message := message_model.Message{ + MessageID: evt.Info.ID, + Timestamp: evt.Info.Timestamp.Format("2006-01-02 15:04:05"), + Status: "Received", + Source: evt.Info.Chat.ToNonAD().User, + Referral: referral, + } + + mycli.persistMessageAsync(message) + } + // ===== BUTTON CLICK EVENT DETECTION ===== // Detecta cliques em botões e emite evento separado "ButtonClick" // Suporta 3 formatos: ButtonsResponseMessage, InteractiveResponseMessage (NativeFlow), TemplateButtonReplyMessage @@ -1577,17 +1607,17 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { buttonClickMap := map[string]interface{}{ "event": "ButtonClick", "data": map[string]interface{}{ - "buttonId": buttonClickData["buttonId"], - "buttonText": buttonClickData["buttonText"], - "type": buttonClickData["type"], - "phone": dataMap["Sender"], - "jid": dataMap["Sender"], - "pushName": dataMap["PushName"], - "messageId": dataMap["ID"], - "chat": dataMap["Chat"], - "fromMe": dataMap["FromMe"], - "timestamp": evt.Info.Timestamp.Unix(), - "extraData": buttonClickData, + "buttonId": buttonClickData["buttonId"], + "buttonText": buttonClickData["buttonText"], + "type": buttonClickData["type"], + "phone": dataMap["Sender"], + "jid": dataMap["Sender"], + "pushName": dataMap["PushName"], + "messageId": dataMap["ID"], + "chat": dataMap["Chat"], + "fromMe": dataMap["FromMe"], + "timestamp": evt.Info.Timestamp.Unix(), + "extraData": buttonClickData, }, "instanceToken": mycli.token, "instanceId": mycli.userID, @@ -1643,7 +1673,7 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { message.Source = evt.Chat.ToNonAD().User if mycli.config.DatabaseSaveMessages { - go mycli.messageRepository.InsertMessage(message) + mycli.persistMessageAsync(message) } } } else { @@ -1668,7 +1698,7 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { mycli.processedMessages.Set(messageKey, true, 30*time.Minute) if mycli.config.DatabaseSaveMessages { - go mycli.messageRepository.InsertMessage(message) + mycli.persistMessageAsync(message) } mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Message delivered to %s", mycli.userID, evt.SourceString())