diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 8e026a87..f1095e51 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("/contacts", 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/save_contact.go b/pkg/user/handler/save_contact.go new file mode 100644 index 00000000..025e447e --- /dev/null +++ b/pkg/user/handler/save_contact.go @@ -0,0 +1,53 @@ +package user_handler + +// HTTP handler for saving a contact to the device addressbook. +// See the rationale in pkg/user/service/save_contact.go. + +import ( + "net/http" + + instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" + user_service "github.com/evolution-foundation/evolution-go/pkg/user/service" + "github.com/gin-gonic/gin" +) + +// Save a contact to the device addressbook +// @Summary Save a contact +// @Description Save/update a contact in the WhatsApp contact list (app state), asking the +// @Description primary device to also store it in the system addressbook. +// @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/contacts [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 len(data.Number) < 1 || len(data.FullName) < 1 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "phone and fullName are 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"}) +} diff --git a/pkg/user/handler/user_handler.go b/pkg/user/handler/user_handler.go index 173fa920..806e3ab4 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) diff --git a/pkg/user/service/save_contact.go b/pkg/user/service/save_contact.go new file mode 100644 index 00000000..cbff2756 --- /dev/null +++ b/pkg/user/service/save_contact.go @@ -0,0 +1,79 @@ +package user_service + +// Save a contact to the device addressbook. +// +// WhatsApp syncs the contact list across devices via APP STATE (the same +// channel as mute/pin/archive): a "contact"-index mutation on the +// critical_unblock_low patch — which whatsmeow itself applies back into +// Store.Contacts on receipt (appstate.go, IndexContact → PutContactName). +// The API currently exposes only the READ side (GET /user/contacts); this +// adds the WRITE side: build the ContactAction (FullName/FirstName + +// SaveOnPrimaryAddressbook, which asks the primary phone to also store it in +// the SYSTEM addressbook) and send it with the official primitive +// Client.SendAppState. After the resync SendAppState triggers, the contact +// shows up in GET /user/contacts. +// +// Own file + minimal call-site lines (interface/handler/route) to keep the +// change easy to review and rebase. + +import ( + "context" + "errors" + "strings" + + instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model" + "go.mau.fi/whatsmeow/appstate" + waSyncAction "go.mau.fi/whatsmeow/proto/waSyncAction" + "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" +) + +type SaveContactStruct struct { + Number string `json:"phone"` + FullName string `json:"fullName"` + FirstName string `json:"firstName"` +} + +// contactMutationVersion is the static version of the "contact" index +// mutation (each index has its own — mute=2, pin=5, …; reference +// implementations use 2 for contact). A wrong value fails CLEANLY in +// SendAppState (the server rejects the patch), with no side effects. +const contactMutationVersion = 2 + +func (u *userService) SaveContact(data *SaveContactStruct, instance *instance_model.Instance) error { + client, err := u.ensureClientConnected(instance.Id) + if err != nil { + return err + } + number := strings.TrimSpace(data.Number) + fullName := strings.TrimSpace(data.FullName) + if number == "" || fullName == "" { + return errors.New("phone and fullName are required") + } + firstName := strings.TrimSpace(data.FirstName) + if firstName == "" { + firstName = strings.Fields(fullName)[0] + } + jid := types.NewJID(number, types.DefaultUserServer) + + patch := appstate.PatchInfo{ + Type: appstate.WAPatchCriticalUnblockLow, // the patch that carries the contact list + Mutations: []appstate.MutationInfo{{ + Index: []string{appstate.IndexContact, jid.String()}, + Version: contactMutationVersion, + Value: &waSyncAction.SyncActionValue{ + ContactAction: &waSyncAction.ContactAction{ + FullName: proto.String(fullName), + FirstName: proto.String(firstName), + SaveOnPrimaryAddressbook: proto.Bool(true), + }, + }, + }}, + } + if err := client.SendAppState(context.Background(), patch); err != nil { + u.loggerWrapper.GetLogger(instance.Id).LogError("[%s] SaveContact %s: %v", instance.Id, jid.String(), err) + return err + } + u.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Contact saved to addressbook: %s (%s)", instance.Id, fullName, jid.String()) + return nil +} diff --git a/pkg/user/service/user_service.go b/pkg/user/service/user_service.go index f3011606..2a27fecb 100644 --- a/pkg/user/service/user_service.go +++ b/pkg/user/service/user_service.go @@ -22,6 +22,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)