From a7d3745bf89db9757d7eaf1f4d6917daa782d108 Mon Sep 17 00:00:00 2001 From: Thiago Bauken <107090829+ThiagoBauken@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:28:57 -0300 Subject: [PATCH] feat: add GET/POST /user/privacy to read and set privacy settings Exposes whatsmeow's privacy settings through two endpoints: GET /user/privacy returns the account's current settings POST /user/privacy {Name,Value} updates a single setting Both follow the existing handler pattern: per-token auth, "no session" when the client isn't connected, and a request-scoped context with a 30s timeout. Input is validated before it reaches the server. validatePrivacySetting checks that the name is supported and the value is allowed for that name, using the matrix documented in whatsmeow's types. The exposed set is deliberately the seven settings that whatsmeow's (*Client).SetPrivacySetting round-trips into the returned/cached PrivacySettings. The protocol also defines messages/defense/ stickers, but whatsmeow's setter has no switch case for them (the change is sent to the server yet not reflected back), so they are intentionally rejected and a test locks that boundary. Purely additive: two new routes, no change to existing behavior. Covered by unit tests for the validation matrix and an integration test that drives both routes through the real router and auth middleware. Docs added to API.md and the OpenAPI spec. --- API.md | 70 +++++++++++++++++++++++++++++ handlers.go | 107 ++++++++++++++++++++++++++++++++++++++++++++ privacy_test.go | 87 +++++++++++++++++++++++++++++++++++ routes.go | 2 + static/api/spec.yml | 42 +++++++++++++++++ 5 files changed, 308 insertions(+) create mode 100644 privacy_test.go diff --git a/API.md b/API.md index 3e7f955f..c6a2216d 100644 --- a/API.md +++ b/API.md @@ -643,6 +643,76 @@ Response: --- +## Get Privacy Settings + +Returns the account's current privacy settings. + +Endpoint: _/user/privacy_ + +Method: **GET** + +``` +curl -s -X GET -H 'Token: 1234ABCD' http://localhost:8080/user/privacy +``` + +Response: + +```json +{ + "code": 200, + "data": { + "GroupAdd": "contacts", + "LastSeen": "all", + "Status": "contacts", + "Profile": "all", + "ReadReceipts": "all", + "CallAdd": "all", + "Online": "all", + "Messages": "all", + "Defense": "off", + "Stickers": "contacts" + }, + "success": true +} +``` + +--- + +## Set Privacy Setting + +Updates a single privacy setting. + +Endpoint: _/user/privacy_ + +Method: **POST** + +Valid `Name` / `Value` combinations: + +| Name | Valid values | +|------|--------------| +| `groupadd`, `last`, `status`, `profile` | `all`, `contacts`, `contact_blacklist`, `none` | +| `readreceipts` | `all`, `none` | +| `online` | `all`, `match_last_seen` | +| `calladd` | `all`, `known` | + +``` +curl -s -X POST -H 'Token: 1234ABCD' -H 'Content-Type: application/json' --data '{"Name":"last","Value":"contacts"}' http://localhost:8080/user/privacy +``` + +Response: + +```json +{ + "code": 200, + "data": { + "LastSeen": "contacts" + }, + "success": true +} +``` + +--- + ## Block User Blocks a WhatsApp user and returns the updated blocklist. diff --git a/handlers.go b/handlers.go index 81615792..4b77c3db 100644 --- a/handlers.go +++ b/handlers.go @@ -6951,6 +6951,113 @@ func (s *server) GetUserLID() http.HandlerFunc { } } +// privacySettingValues maps each settable privacy setting to the values WhatsApp +// accepts for it, using the matrix documented in whatsmeow's types. Used to reject +// invalid input before it reaches the server. +// +// This is deliberately the subset that whatsmeow's (*Client).SetPrivacySetting +// round-trips: its switch updates the returned/cached PrivacySettings only for +// these seven names. The protocol also defines "messages", "defense" and +// "stickers" (see types.PrivacySettingType), but whatsmeow's setter has no case +// for them, so a change would be sent to the server yet leave the response and +// cache stale. We expose only what round-trips correctly; revisit if whatsmeow +// adds those cases. +var privacySettingValues = map[types.PrivacySettingType][]types.PrivacySetting{ + types.PrivacySettingTypeGroupAdd: {types.PrivacySettingAll, types.PrivacySettingContacts, types.PrivacySettingContactBlacklist, types.PrivacySettingNone}, + types.PrivacySettingTypeLastSeen: {types.PrivacySettingAll, types.PrivacySettingContacts, types.PrivacySettingContactBlacklist, types.PrivacySettingNone}, + types.PrivacySettingTypeStatus: {types.PrivacySettingAll, types.PrivacySettingContacts, types.PrivacySettingContactBlacklist, types.PrivacySettingNone}, + types.PrivacySettingTypeProfile: {types.PrivacySettingAll, types.PrivacySettingContacts, types.PrivacySettingContactBlacklist, types.PrivacySettingNone}, + types.PrivacySettingTypeReadReceipts: {types.PrivacySettingAll, types.PrivacySettingNone}, + types.PrivacySettingTypeOnline: {types.PrivacySettingAll, types.PrivacySettingMatchLastSeen}, + types.PrivacySettingTypeCallAdd: {types.PrivacySettingAll, types.PrivacySettingKnown}, +} + +// validatePrivacySetting reports whether name is a supported privacy setting and +// value is one of the values WhatsApp accepts for it. +func validatePrivacySetting(name, value string) error { + allowed, ok := privacySettingValues[types.PrivacySettingType(name)] + if !ok { + return fmt.Errorf("invalid privacy setting name %q", name) + } + for _, v := range allowed { + if types.PrivacySetting(value) == v { + return nil + } + } + return fmt.Errorf("invalid value %q for privacy setting %q", value, name) +} + +// GetPrivacySettings returns the account's current privacy settings. +func (s *server) GetPrivacySettings() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + client := clientManager.GetWhatsmeowClient(txtid) + if client == nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("no session")) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + settings, err := client.TryFetchPrivacySettings(ctx, false) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, fmt.Errorf("failed to get privacy settings: %w", err)) + return + } + + responseJson, err := json.Marshal(settings) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, err) + } else { + s.Respond(w, r, http.StatusOK, string(responseJson)) + } + } +} + +// SetPrivacySetting updates a single privacy setting (e.g. last seen, profile photo, +// read receipts). Pass {"Name": "...", "Value": "..."}. +func (s *server) SetPrivacySetting() http.HandlerFunc { + type privacyRequest struct { + Name string `json:"Name"` + Value string `json:"Value"` + } + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + client := clientManager.GetWhatsmeowClient(txtid) + if client == nil { + s.Respond(w, r, http.StatusInternalServerError, errors.New("no session")) + return + } + + var t privacyRequest + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) + return + } + if err := validatePrivacySetting(t.Name, t.Value); err != nil { + s.Respond(w, r, http.StatusBadRequest, err) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + settings, err := client.SetPrivacySetting(ctx, types.PrivacySettingType(t.Name), types.PrivacySetting(t.Value)) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, fmt.Errorf("failed to set privacy setting: %w", err)) + return + } + + responseJson, err := json.Marshal(settings) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, err) + } else { + s.Respond(w, r, http.StatusOK, string(responseJson)) + } + } +} + // RequestUnavailableMessage requests a copy of a message that couldn't be decrypted func (s *server) RequestUnavailableMessage() http.HandlerFunc { diff --git a/privacy_test.go b/privacy_test.go new file mode 100644 index 00000000..ca27952a --- /dev/null +++ b/privacy_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestValidatePrivacySetting covers the input validation for POST /user/privacy: +// a setting name must be one of the supported types and the value must be allowed +// for that specific setting (per the whatsmeow-documented matrix). +func TestValidatePrivacySetting(t *testing.T) { + cases := []struct { + name, value string + wantErr bool + }{ + {"last", "all", false}, + {"last", "contacts", false}, + {"last", "contact_blacklist", false}, + {"last", "none", false}, + {"groupadd", "contacts", false}, + {"status", "none", false}, + {"profile", "all", false}, + {"readreceipts", "all", false}, + {"readreceipts", "none", false}, + {"online", "match_last_seen", false}, + {"calladd", "known", false}, + // invalid value for the given setting + {"readreceipts", "contacts", true}, + {"online", "contacts", true}, + {"calladd", "contacts", true}, + {"last", "sometimes", true}, + // Protocol-known but deliberately not exposed: whatsmeow's SetPrivacySetting + // has no switch case for these, so a change would not round-trip into the + // returned/cached settings. The values below ARE valid per the documented + // matrix, proving they're rejected for the NAME, not the value. + {"messages", "all", true}, + {"defense", "off", true}, + {"stickers", "contacts", true}, + // unknown / empty name + {"bogus", "all", true}, + {"", "", true}, + } + for _, tc := range cases { + t.Run(tc.name+"="+tc.value, func(t *testing.T) { + err := validatePrivacySetting(tc.name, tc.value) + if (err != nil) != tc.wantErr { + t.Errorf("validatePrivacySetting(%q, %q) err=%v; wantErr=%v", tc.name, tc.value, err, tc.wantErr) + } + }) + } +} + +// TestPrivacyEndpoints drives GET and POST /user/privacy through the real router +// and auth middleware. With no client connected the handler returns "no session"; +// the point is to prove both routes are wired (not 404) and auth passes (not 401). +func TestPrivacyEndpoints(t *testing.T) { + s := makeTestServer(t) + const token = "tok-privacy" + if _, err := s.db.Exec( + `INSERT INTO users (id, name, token, connected) VALUES ($1,$2,$3,$4)`, + "u-priv", "tester", token, 0); err != nil { + t.Fatalf("seed user: %v", err) + } + + check := func(t *testing.T, method, body string) { + t.Helper() + req := httptest.NewRequest(method, "/user/privacy", strings.NewReader(body)) + req.Header.Set("token", token) + rr := httptest.NewRecorder() + s.router.ServeHTTP(rr, req) + + if rr.Code == http.StatusNotFound { + t.Fatalf("%s /user/privacy not registered (404)", method) + } + if rr.Code == http.StatusUnauthorized { + t.Fatalf("%s auth failed for a valid token (401): %s", method, rr.Body.String()) + } + if rr.Code != http.StatusInternalServerError || !strings.Contains(rr.Body.String(), "no session") { + t.Errorf("%s expected 500 \"no session\" (route hit, no client); got %d: %s", method, rr.Code, rr.Body.String()) + } + } + + t.Run("GET", func(t *testing.T) { check(t, http.MethodGet, "") }) + t.Run("POST", func(t *testing.T) { check(t, http.MethodPost, `{"Name":"last","Value":"contacts"}`) }) +} diff --git a/routes.go b/routes.go index 54e9ad59..21b40251 100644 --- a/routes.go +++ b/routes.go @@ -135,6 +135,8 @@ func (s *server) routes() { s.router.Handle("/user/unblock", c.Then(s.UnblockUser())).Methods("POST") s.router.Handle("/user/blocklist", c.Then(s.GetBlocklist())).Methods("GET") s.router.Handle("/user/lid/{jid}", c.Then(s.GetUserLID())).Methods("GET") + s.router.Handle("/user/privacy", c.Then(s.GetPrivacySettings())).Methods("GET") + s.router.Handle("/user/privacy", c.Then(s.SetPrivacySetting())).Methods("POST") s.router.Handle("/chat/presence", c.Then(s.ChatPresence())).Methods("POST") s.router.Handle("/chat/markread", c.Then(s.MarkRead())).Methods("POST") diff --git a/static/api/spec.yml b/static/api/spec.yml index 693133e5..c21c9730 100644 --- a/static/api/spec.yml +++ b/static/api/spec.yml @@ -946,6 +946,48 @@ paths: description: Invalid or missing token 404: description: User not found + /user/privacy: + get: + tags: + - User + summary: Get privacy settings + description: Returns the account's current privacy settings (last seen, profile photo, status, read receipts, online, groups, calls). + security: + - ApiKeyAuth: [] + responses: + 200: + description: Current privacy settings + content: + application/json: + schema: + example: { "code": 200, "data": { "GroupAdd": "contacts", "LastSeen": "all", "Status": "contacts", "Profile": "all", "ReadReceipts": "all", "CallAdd": "all", "Online": "all", "Messages": "all", "Defense": "off", "Stickers": "contacts" }, "success": true } + post: + tags: + - User + summary: Set a privacy setting + description: | + Updates a single privacy setting. Valid Name/Value combinations: +
`groupadd`, `last`, `status`, `profile`: `all`, `contacts`, `contact_blacklist`, `none` +
`readreceipts`: `all`, `none` +
`online`: `all`, `match_last_seen` +
`calladd`: `all`, `known` + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + example: { "Name": "last", "Value": "contacts" } + responses: + 200: + description: Updated privacy settings + content: + application/json: + schema: + example: { "code": 200, "data": { "LastSeen": "contacts" }, "success": true } + 400: + description: Invalid privacy setting name or value /chat/delete: post: tags: