Skip to content
Merged
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
70 changes: 70 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
107 changes: 107 additions & 0 deletions handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6952,6 +6952,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 {

Expand Down
87 changes: 87 additions & 0 deletions privacy_test.go
Original file line number Diff line number Diff line change
@@ -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"}`) })
}
2 changes: 2 additions & 0 deletions routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
42 changes: 42 additions & 0 deletions static/api/spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<br>`groupadd`, `last`, `status`, `profile`: `all`, `contacts`, `contact_blacklist`, `none`
<br>`readreceipts`: `all`, `none`
<br>`online`: `all`, `match_last_seen`
<br>`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:
Expand Down
Loading