feat: POST /user/contacts — save a contact to the device addressbook - #162
Conversation
…sbook WhatsApp syncs the contact list across devices via app state (the same channel as mute/pin/archive), and whatsmeow already applies incoming "contact" mutations back into Store.Contacts. The API exposes only the read side (GET /user/contacts); this adds the write side: a "contact"-index mutation on the critical_unblock_low patch carrying a ContactAction (FullName/FirstName + SaveOnPrimaryAddressbook, which asks the primary phone to also store the contact in the system addressbook), sent through the official Client.SendAppState primitive. After the resync SendAppState triggers, the saved contact shows up in GET /user/contacts like any contact saved on the phone itself. Battle-tested in production since 2026-07-22 (verified end to end on a paired Android device: contact appears in the phone's WhatsApp contact list and system addressbook).
Reviewer's GuideAdds a POST /user/contacts endpoint that builds and sends a WhatsApp app-state contact mutation so the primary device saves a contact into both WhatsApp’s contact list and the system address book, wiring it through routes, handler, and service layers with basic validation and logging. Sequence diagram for POST /user/contacts saving a contact via WhatsApp app statesequenceDiagram
actor User
participant GinRouter
participant UserHandler
participant UserService
participant WhatsAppClient
User->>GinRouter: POST /user/contacts
GinRouter->>UserHandler: SaveContact(ctx)
UserHandler->>UserHandler: ctx.MustGet(instance)
UserHandler->>UserHandler: ctx.ShouldBindBodyWithJSON(data)
UserHandler->>UserService: SaveContact(data, instance)
UserService->>UserService: ensureClientConnected(instance.Id)
UserService->>WhatsAppClient: SendAppState(context.Background(), patch)
alt SendAppState error
WhatsAppClient-->>UserService: error
UserService->>UserService: LogError(instance.Id, jid.String(), err)
UserService-->>UserHandler: error
UserHandler-->>User: 500 Internal Server Error
else SendAppState success
WhatsAppClient-->>UserService: ok
UserService->>UserService: LogInfo(instance.Id, fullName, jid.String())
UserService-->>UserHandler: nil
UserHandler-->>User: 200 {message: success}
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In the HTTP handler, you bind into a
*SaveContactStructusingctx.ShouldBindBodyWithJSON(&data), which results in a pointer-to-pointer; consider binding into a value (var data user_service.SaveContactStructand pass&data) to align with Gin’s binding expectations. - The handler performs its own
phone/fullNamepresence check while the service also validates and returns the same error; you could centralize this validation in the service layer to avoid duplication and keep error behavior in one place. - In
SaveContact, you usecontext.Background()forSendAppState; passingctx.Request.Context()from the HTTP handler would let the operation be cancelled if the client disconnects or the request times out.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In the HTTP handler, you bind into a `*SaveContactStruct` using `ctx.ShouldBindBodyWithJSON(&data)`, which results in a pointer-to-pointer; consider binding into a value (`var data user_service.SaveContactStruct` and pass `&data`) to align with Gin’s binding expectations.
- The handler performs its own `phone`/`fullName` presence check while the service also validates and returns the same error; you could centralize this validation in the service layer to avoid duplication and keep error behavior in one place.
- In `SaveContact`, you use `context.Background()` for `SendAppState`; passing `ctx.Request.Context()` from the HTTP handler would let the operation be cancelled if the client disconnects or the request times out.
## Individual Comments
### Comment 1
<location path="pkg/user/handler/save_contact.go" line_range="35-36" />
<code_context>
+ return
+ }
+
+ var data *user_service.SaveContactStruct
+ err := ctx.ShouldBindBodyWithJSON(&data)
+ if err != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
</code_context>
<issue_to_address>
**issue (bug_risk):** Binding into a pointer-to-pointer is likely incorrect and may prevent proper JSON deserialization.
`data` is declared as `*user_service.SaveContactStruct`, so `&data` is `**user_service.SaveContactStruct`, which Gin’s binders don’t expect and may break deserialization. Use a concrete value instead:
```go
var data user_service.SaveContactStruct
if err := ctx.ShouldBindBodyWithJSON(&data); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// pass &data to the service
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| var data *user_service.SaveContactStruct | ||
| err := ctx.ShouldBindBodyWithJSON(&data) |
There was a problem hiding this comment.
issue (bug_risk): Binding into a pointer-to-pointer is likely incorrect and may prevent proper JSON deserialization.
data is declared as *user_service.SaveContactStruct, so &data is **user_service.SaveContactStruct, which Gin’s binders don’t expect and may break deserialization. Use a concrete value instead:
var data user_service.SaveContactStruct
if err := ctx.ShouldBindBodyWithJSON(&data); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// pass &data to the service
WhatsApp syncs the contact list across devices via app state (the same channel as mute/pin/archive), and whatsmeow already applies incoming
contactmutations back intoStore.Contacts. The API exposes only the read side (GET /user/contacts); this adds the write side.POST /user/contacts{phone, fullName, firstName?}builds acontact-index mutation on thecritical_unblock_lowpatch carrying aContactAction(FullName/FirstName+SaveOnPrimaryAddressbook: true, which asks the primary phone to also store the contact in the system addressbook) and sends it through the officialClient.SendAppStateprimitive. After the resync SendAppState triggers, the saved contact shows up inGET /user/contactslike any contact saved on the phone itself.Verified end to end on a paired Android device (contact appears in the phone's WhatsApp list and system addressbook); in our production since 2026-07-22.
Retargeted to
develop(replaces #129), as requested by @iagocotta in #128 (comment).Note that
mainanddevelophave no common ancestor — the GitHub API refuses to compare them (No common ancestor between main and develop) — so the base branch of the original PR could not simply be edited. This branch was created fromdevelopand the commit cherry-picked onto it.The import paths were changed from
evolution-foundation/evolution-gotoEvolutionAPI/evolution-go, which is the module pathdevelopstill declares in itsgo.mod. No other change.Verified on this branch:
go build ./...andgo vet ./...both pass.Summary by Sourcery
Add support for saving WhatsApp contacts to the device address book via a new POST /user/contacts endpoint wired through the user handler and service.
New Features:
Enhancements: