From 4b6450ef3233f5d39b60c302f35a13a55c97ceda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20Pl=C3=A1cido=20da=20Costa?= Date: Fri, 17 Jul 2026 11:23:21 -0300 Subject: [PATCH] feat: adiciona endpoint POST /user/savecontact --- pkg/routes/routes.go | 1 + pkg/user/handler/user_handler.go | 48 ++++++++++++++++++ pkg/user/service/user_service.go | 87 ++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 8e026a87..d4786b41 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -134,6 +134,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { 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.POST("/savecontact", r.jidValidationMiddleware.ValidateNumberField(), r.userHandler.SaveContact) routes.GET("/privacy", r.userHandler.GetPrivacy) routes.POST("/privacy", r.userHandler.SetPrivacy) routes.POST("/block", r.jidValidationMiddleware.ValidateNumberField(), r.userHandler.BlockContact) diff --git a/pkg/user/handler/user_handler.go b/pkg/user/handler/user_handler.go index 173fa920..65e3ef84 100644 --- a/pkg/user/handler/user_handler.go +++ b/pkg/user/handler/user_handler.go @@ -13,6 +13,7 @@ type UserHandler interface { CheckUser(ctx *gin.Context) GetAvatar(ctx *gin.Context) GetContacts(ctx *gin.Context) + SaveContact(ctx *gin.Context) GetPrivacy(ctx *gin.Context) SetPrivacy(ctx *gin.Context) BlockContact(ctx *gin.Context) @@ -182,6 +183,53 @@ func (u *userHandler) GetContacts(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"message": "success", "data": contacts}) } +// Save a contact +// @Summary Save a contact +// @Description Adiciona/atualiza um contato na lista de contatos do WhatsApp da instância +// @Description (mesmo mecanismo da tela "Novo contato" do WhatsApp Web). Com `saveOnPhone: true` +// @Description (padrão), sincroniza também com a agenda do celular primário. +// @Tags User +// @Accept json +// @Produce json +// @Param message body user_service.SaveContactStruct true "Contact data" +// @Success 200 {object} gin.H "success" +// @Failure 400 {object} gin.H "Error on validation" +// @Failure 500 {object} gin.H "Internal server error" +// @Router /user/savecontact [post] +func (u *userHandler) SaveContact(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 *user_service.SaveContactStruct + 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.FullName == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "fullName is required"}) + return + } + + if err := u.userService.SaveContact(data, instance); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "success"}) +} + // Get a user's privacy settings // @Summary Get a user's privacy settings // @Description Get a user's privacy settings diff --git a/pkg/user/service/user_service.go b/pkg/user/service/user_service.go index f3011606..d44fecf8 100644 --- a/pkg/user/service/user_service.go +++ b/pkg/user/service/user_service.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" @@ -13,8 +14,11 @@ import ( "github.com/evolution-foundation/evolution-go/pkg/utils" whatsmeow_service "github.com/evolution-foundation/evolution-go/pkg/whatsmeow/service" "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/appstate" + "go.mau.fi/whatsmeow/proto/waSyncAction" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" ) type UserService interface { @@ -22,6 +26,7 @@ type UserService interface { CheckUser(data *CheckUserStruct, instance *instance_model.Instance) (*CheckUserCollection, error) GetAvatar(data *GetAvatarStruct, instance *instance_model.Instance) (*types.ProfilePictureInfo, error) GetContacts(instance *instance_model.Instance) ([]ContactInfo, error) + SaveContact(data *SaveContactStruct, instance *instance_model.Instance) error GetPrivacy(instance *instance_model.Instance) (types.PrivacySettings, error) SetPrivacy(data *PrivacyStruct, instance *instance_model.Instance) (*types.PrivacySettings, error) BlockContact(data *BlockStruct, instance *instance_model.Instance) (*types.Blocklist, error) @@ -86,6 +91,19 @@ type BlockStruct struct { Number string `json:"number"` } +// SaveContactStruct é o body de POST /user/savecontact. +type SaveContactStruct struct { + // Número de destino (com DDI). + Number string `json:"number" example:"5582988898565"` + // Nome completo salvo para o contato. + FullName string `json:"fullName" example:"Fulano de Tal"` + // Primeiro nome (opcional). Se vazio, usa a primeira palavra de fullName. + FirstName string `json:"firstName,omitempty" example:"Fulano"` + // Se true (padrão), sincroniza com a agenda do celular primário + // (equivale ao toggle "Sincronizar contato com celular" do WhatsApp Web). + SaveOnPhone *bool `json:"saveOnPhone,omitempty"` +} + type SetProfilePictureStruct struct { Image string `json:"image"` } @@ -391,6 +409,75 @@ func (u *userService) GetContacts(instance *instance_model.Instance) ([]ContactI } +// SaveContact adiciona/atualiza um contato na lista de contatos do WhatsApp da +// instância, via app state patch (coleção critical_unblock_low, índice "contact"). +// É o mesmo mecanismo que o WhatsApp Web usa na tela "Novo contato". +// +// Quando SaveOnPhone=true, seta ContactAction.SaveOnPrimaryAddressbook, que faz o +// dispositivo primário (celular) gravar o contato também na agenda do sistema — +// equivale ao toggle "Sincronizar contato com celular". +// +// Requisitos: as app state keys precisam já estar sincronizadas (acontece após o +// pareamento) e o celular primário precisa estar online para propagar a mudança. +func (u *userService) SaveContact(data *SaveContactStruct, instance *instance_model.Instance) error { + client, err := u.ensureClientConnected(instance.Id) + if err != nil { + return err + } + + jid, ok := utils.ParseJID(data.Number) + if !ok { + return errors.New("invalid phone number") + } + // Normaliza o JID removendo o '+' inicial para ficar idêntico ao padrão dos + // demais contatos (556284875027@s.whatsapp.net). Com o '+' o WhatsApp aceita + // o app state mas o dispositivo primário não grava na agenda do sistema. + jid.User = strings.ReplaceAll(jid.User, "+", "") + + if data.FullName == "" { + return errors.New("fullName is required") + } + + firstName := data.FirstName + if firstName == "" { + // Usa a primeira palavra do nome completo como fallback. + if idx := strings.IndexByte(data.FullName, ' '); idx > 0 { + firstName = data.FullName[:idx] + } else { + firstName = data.FullName + } + } + + // Por padrão sincroniza com a agenda do celular (toggle do WhatsApp Web). + saveOnPhone := true + if data.SaveOnPhone != nil { + saveOnPhone = *data.SaveOnPhone + } + + patch := appstate.PatchInfo{ + Type: appstate.WAPatchCriticalUnblockLow, + Mutations: []appstate.MutationInfo{{ + Index: []string{appstate.IndexContact, jid.String()}, + Version: 2, + Value: &waSyncAction.SyncActionValue{ + ContactAction: &waSyncAction.ContactAction{ + FullName: proto.String(data.FullName), + FirstName: proto.String(firstName), + SaveOnPrimaryAddressbook: proto.Bool(saveOnPhone), + }, + }, + }}, + } + + if err := client.SendAppState(context.Background(), patch); err != nil { + u.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error saving contact %s: %v", instance.Id, jid.String(), err) + return err + } + + u.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Contact saved: %s (%s)", instance.Id, data.FullName, jid.String()) + return nil +} + func (u *userService) GetPrivacy(instance *instance_model.Instance) (types.PrivacySettings, error) { client, err := u.ensureClientConnected(instance.Id) if err != nil {