Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions pkg/user/handler/user_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Comment on lines +208 to +209

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Request body binding is using a pointer to pointer, which is unlikely to work as intended.

data is declared as *user_service.SaveContactStruct, but &data is passed to ShouldBindBodyWithJSON, producing a **SaveContactStruct. Gin expects a single struct pointer. Declare var data user_service.SaveContactStruct and call ctx.ShouldBindBodyWithJSON(&data) instead, otherwise the body is unlikely to bind correctly.

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
Expand Down
87 changes: 87 additions & 0 deletions pkg/user/service/user_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,27 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"

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"
"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 {
GetUser(data *CheckUserStruct, instance *instance_model.Instance) (*UserCollection, error)
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)
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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 {
Expand Down