From 92024bd3bd4a4e11d0147f2df299ebd71cb56d05 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 04:08:02 +0300 Subject: [PATCH 001/163] build: pin generated business MEX bindings --- .github/workflows/go.yml | 3 + internal/cmd/genmex/main.go | 126 ++++++++++++++++++++++++++++++++++++ mex/bindings.go | 48 ++++++++++++++ mex/bindings_test.go | 30 +++++++++ mex/generate.go | 3 + mex/spec.json | 41 ++++++++++++ 6 files changed, 251 insertions(+) create mode 100644 internal/cmd/genmex/main.go create mode 100644 mex/bindings.go create mode 100644 mex/bindings_test.go create mode 100644 mex/generate.go create mode 100644 mex/spec.json diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index aa8188501..00eeae11d 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,6 +22,9 @@ jobs: with: go-version: ${{ matrix.go-version }} + - name: Check generated MEX bindings + run: go run ./internal/cmd/genmex -input mex/spec.json -output mex/bindings.go -check + - name: Build run: go build -v ./... diff --git a/internal/cmd/genmex/main.go b/internal/cmd/genmex/main.go new file mode 100644 index 000000000..27f6ba238 --- /dev/null +++ b/internal/cmd/genmex/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "go/format" + "math/big" + "os" + "regexp" + "sort" + "strings" +) + +type operation struct { + DocumentID string `json:"documentId"` + Kind string `json:"kind"` + ResponseDiscriminator string `json:"responseDiscriminator"` +} + +type spec struct { + SourceRevision string `json:"sourceRevision"` + WAVersion string `json:"waVersion"` + Operations map[string]operation `json:"operations"` +} + +var ( + hexRevision = regexp.MustCompile(`^[0-9a-f]{40}$`) + goName = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) +) + +func main() { + input := flag.String("input", "mex/spec.json", "input specification") + output := flag.String("output", "mex/bindings.go", "generated Go file") + check := flag.Bool("check", false, "fail when output differs") + flag.Parse() + + data, err := os.ReadFile(*input) + checkErr(err) + var parsed spec + checkErr(json.Unmarshal(data, &parsed)) + checkErr(validate(parsed)) + generated, err := render(parsed) + checkErr(err) + + if *check { + current, err := os.ReadFile(*output) + checkErr(err) + if !bytes.Equal(current, generated) { + checkErr(fmt.Errorf("%s is not generated from %s", *output, *input)) + } + return + } + checkErr(os.WriteFile(*output, generated, 0o644)) +} + +func validate(parsed spec) error { + if !hexRevision.MatchString(parsed.SourceRevision) { + return fmt.Errorf("invalid source revision %q", parsed.SourceRevision) + } + if strings.TrimSpace(parsed.WAVersion) == "" { + return fmt.Errorf("missing WhatsApp Web version") + } + if len(parsed.Operations) == 0 { + return fmt.Errorf("no operations") + } + for name, op := range parsed.Operations { + if !goName.MatchString(name) { + return fmt.Errorf("invalid operation name %q", name) + } + id, ok := new(big.Int).SetString(op.DocumentID, 10) + if !ok || id.Sign() <= 0 { + return fmt.Errorf("invalid document ID for %s", name) + } + if op.Kind != "query" && op.Kind != "mutation" { + return fmt.Errorf("invalid operation kind %q for %s", op.Kind, name) + } + if strings.TrimSpace(op.ResponseDiscriminator) == "" { + return fmt.Errorf("missing response discriminator for %s", name) + } + } + return nil +} + +func render(parsed spec) ([]byte, error) { + names := make([]string, 0, len(parsed.Operations)) + for name := range parsed.Operations { + names = append(names, name) + } + sort.Strings(names) + + var out strings.Builder + out.WriteString("// Code generated by internal/cmd/genmex; DO NOT EDIT.\n\n") + out.WriteString("package mex\n\n") + out.WriteString("type OperationName string\n\n") + out.WriteString("type OperationKind string\n\n") + out.WriteString("const (\n\tKindQuery OperationKind = \"query\"\n\tKindMutation OperationKind = \"mutation\"\n)\n\n") + fmt.Fprintf(&out, "const SourceRevision = %q\n\n", parsed.SourceRevision) + fmt.Fprintf(&out, "const WebVersion = %q\n\n", parsed.WAVersion) + out.WriteString("const (\n") + for _, name := range names { + fmt.Fprintf(&out, "\t%s OperationName = %q\n", name, name) + } + out.WriteString(")\n\n") + out.WriteString("type Operation struct {\n\tName OperationName\n\tDocumentID string\n\tKind OperationKind\n\tResponseDiscriminator string\n}\n\n") + out.WriteString("var operations = map[OperationName]Operation{\n") + for _, name := range names { + op := parsed.Operations[name] + kind := "KindQuery" + if op.Kind == "mutation" { + kind = "KindMutation" + } + fmt.Fprintf(&out, "\t%s: {Name: %s, DocumentID: %q, Kind: %s, ResponseDiscriminator: %q},\n", name, name, op.DocumentID, kind, op.ResponseDiscriminator) + } + out.WriteString("}\n\n") + out.WriteString("func Lookup(name OperationName) (Operation, bool) {\n\top, ok := operations[name]\n\treturn op, ok\n}\n") + return format.Source([]byte(out.String())) +} + +func checkErr(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/mex/bindings.go b/mex/bindings.go new file mode 100644 index 000000000..442b91e80 --- /dev/null +++ b/mex/bindings.go @@ -0,0 +1,48 @@ +// Code generated by internal/cmd/genmex; DO NOT EDIT. + +package mex + +type OperationName string + +type OperationKind string + +const ( + KindQuery OperationKind = "query" + KindMutation OperationKind = "mutation" +) + +const SourceRevision = "74509efe262b37b26bed05486c9f4160db5e841b" + +const WebVersion = "2.3000.1044776264" + +const ( + BizCreateOrder OperationName = "BizCreateOrder" + BizQueryOrder OperationName = "BizQueryOrder" + QueryCatalog OperationName = "QueryCatalog" + QueryCatalogProduct OperationName = "QueryCatalogProduct" + QueryProductCollections OperationName = "QueryProductCollections" + QueryProductListCatalog OperationName = "QueryProductListCatalog" + QueryProductSingleCollection OperationName = "QueryProductSingleCollection" +) + +type Operation struct { + Name OperationName + DocumentID string + Kind OperationKind + ResponseDiscriminator string +} + +var operations = map[OperationName]Operation{ + BizCreateOrder: {Name: BizCreateOrder, DocumentID: "26486627094287046", Kind: KindMutation, ResponseDiscriminator: "xwa_checkout_place_order"}, + BizQueryOrder: {Name: BizQueryOrder, DocumentID: "26593811266898374", Kind: KindQuery, ResponseDiscriminator: "xwa_checkout_get_order_info"}, + QueryCatalog: {Name: QueryCatalog, DocumentID: "30445081048424116", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_product_catalog"}, + QueryCatalogProduct: {Name: QueryCatalogProduct, DocumentID: "9660926520672123", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_product"}, + QueryProductCollections: {Name: QueryProductCollections, DocumentID: "9430970660362540", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_collections"}, + QueryProductListCatalog: {Name: QueryProductListCatalog, DocumentID: "30125049463760630", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_product_list"}, + QueryProductSingleCollection: {Name: QueryProductSingleCollection, DocumentID: "9546992575408789", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_single_collection"}, +} + +func Lookup(name OperationName) (Operation, bool) { + op, ok := operations[name] + return op, ok +} diff --git a/mex/bindings_test.go b/mex/bindings_test.go new file mode 100644 index 000000000..5fbcc5001 --- /dev/null +++ b/mex/bindings_test.go @@ -0,0 +1,30 @@ +package mex + +import "testing" + +func TestCatalogBindingsMatchPinnedSpec(t *testing.T) { + if SourceRevision != "74509efe262b37b26bed05486c9f4160db5e841b" { + t.Fatalf("source revision = %q", SourceRevision) + } + tests := map[OperationName]string{ + QueryCatalog: "30445081048424116", + QueryCatalogProduct: "9660926520672123", + QueryProductCollections: "9430970660362540", + QueryProductListCatalog: "30125049463760630", + QueryProductSingleCollection: "9546992575408789", + BizCreateOrder: "26486627094287046", + BizQueryOrder: "26593811266898374", + } + for name, wantID := range tests { + binding, ok := Lookup(name) + if !ok || binding.DocumentID != wantID { + t.Errorf("%s = %#v, %t; want document ID %s", name, binding, ok, wantID) + } + } +} + +func TestLookupRejectsUnknownOperation(t *testing.T) { + if _, ok := Lookup(OperationName("unknown")); ok { + t.Fatal("unknown operation unexpectedly resolved") + } +} diff --git a/mex/generate.go b/mex/generate.go new file mode 100644 index 000000000..6d4f332d4 --- /dev/null +++ b/mex/generate.go @@ -0,0 +1,3 @@ +package mex + +//go:generate go run ../internal/cmd/genmex -input spec.json -output bindings.go diff --git a/mex/spec.json b/mex/spec.json new file mode 100644 index 000000000..550b98d25 --- /dev/null +++ b/mex/spec.json @@ -0,0 +1,41 @@ +{ + "sourceRevision": "74509efe262b37b26bed05486c9f4160db5e841b", + "waVersion": "2.3000.1044776264", + "operations": { + "BizCreateOrder": { + "documentId": "26486627094287046", + "kind": "mutation", + "responseDiscriminator": "xwa_checkout_place_order" + }, + "BizQueryOrder": { + "documentId": "26593811266898374", + "kind": "query", + "responseDiscriminator": "xwa_checkout_get_order_info" + }, + "QueryCatalog": { + "documentId": "30445081048424116", + "kind": "query", + "responseDiscriminator": "xwa_product_catalog_get_product_catalog" + }, + "QueryCatalogProduct": { + "documentId": "9660926520672123", + "kind": "query", + "responseDiscriminator": "xwa_product_catalog_get_product" + }, + "QueryProductCollections": { + "documentId": "9430970660362540", + "kind": "query", + "responseDiscriminator": "xwa_product_catalog_get_collections" + }, + "QueryProductListCatalog": { + "documentId": "30125049463760630", + "kind": "query", + "responseDiscriminator": "xwa_product_catalog_get_product_list" + }, + "QueryProductSingleCollection": { + "documentId": "9546992575408789", + "kind": "query", + "responseDiscriminator": "xwa_product_catalog_get_single_collection" + } + } +} From a29379a210b3f6d701166b9abc591d45364f149e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 04:12:42 +0300 Subject: [PATCH 002/163] feat: add Business App catalog reads --- business_catalog.go | 419 ++++++++++++++++++++++++++++++++++++++ business_catalog_test.go | 156 ++++++++++++++ types/business_catalog.go | 141 +++++++++++++ 3 files changed, 716 insertions(+) create mode 100644 business_catalog.go create mode 100644 business_catalog_test.go create mode 100644 types/business_catalog.go diff --git a/business_catalog.go b/business_catalog.go new file mode 100644 index 000000000..27b8fe387 --- /dev/null +++ b/business_catalog.go @@ -0,0 +1,419 @@ +package whatsmeow + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "go.mau.fi/whatsmeow/mex" + "go.mau.fi/whatsmeow/types" +) + +type GetCatalogParams struct { + After string + Limit int + Width int + Height int +} + +type GetCollectionsParams struct { + After string + CollectionLimit int + ItemLimit int + Width int + Height int +} + +func decodeCatalogPage(data json.RawMessage) (*types.BusinessCatalogPage, error) { + var response struct { + Catalog *struct { + ProductCatalog *struct { + Paging *struct { + After string `json:"after"` + Before string `json:"before"` + } `json:"paging"` + Products []types.BusinessProduct `json:"products"` + } `json:"product_catalog"` + } `json:"xwa_product_catalog_get_product_catalog"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("decode catalog response: %w", err) + } + if response.Catalog == nil || response.Catalog.ProductCatalog == nil { + return nil, fmt.Errorf("catalog response is missing xwa_product_catalog_get_product_catalog.product_catalog") + } + page := &types.BusinessCatalogPage{Products: response.Catalog.ProductCatalog.Products} + if page.Products == nil { + page.Products = []types.BusinessProduct{} + } + if response.Catalog.ProductCatalog.Paging != nil { + page.Next = response.Catalog.ProductCatalog.Paging.After + page.Previous = response.Catalog.ProductCatalog.Paging.Before + } + return page, nil +} + +func buildCatalogVariables(jid types.JID, params GetCatalogParams) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if len(params.After) > 2048 { + return nil, fmt.Errorf("catalog cursor exceeds 2048 bytes") + } + if params.Limit == 0 { + params.Limit = 50 + } + if params.Limit < 1 || params.Limit > 100 { + return nil, fmt.Errorf("catalog limit must be between 1 and 100") + } + width, height, err := normalizeDimensions(params.Width, params.Height) + if err != nil { + return nil, err + } + + request := map[string]any{ + "jid": jid.ToNonAD().String(), + "limit": strconv.Itoa(params.Limit), + "width": strconv.Itoa(width), + "height": strconv.Itoa(height), + "variant_thumbnail_width": strconv.Itoa(width), + "variant_thumbnail_height": strconv.Itoa(height), + "variant_info_fields": map[string]any{}, + "allow_shop_source": "ALLOWSHOPSOURCE_FALSE", + } + if params.After != "" { + request["after"] = params.After + } + return map[string]any{"request": map[string]any{"product_catalog": request}}, nil +} + +func validateBusinessJID(jid types.JID) error { + if jid.IsEmpty() { + return fmt.Errorf("business JID is empty") + } + if jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer { + return fmt.Errorf("business JID must be a user or LID JID") + } + return nil +} + +func buildCatalogProductVariables(jid types.JID, productID string, width, height int) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if err := validateBusinessID("product", productID); err != nil { + return nil, err + } + width, height, err := normalizeDimensions(width, height) + if err != nil { + return nil, err + } + return map[string]any{"request": map[string]any{"product": map[string]any{ + "jid": jid.ToNonAD().String(), + "product_id": productID, + "width": strconv.Itoa(width), + "height": strconv.Itoa(height), + "variant_thumbnail_width": strconv.Itoa(width), + "variant_thumbnail_height": strconv.Itoa(height), + "variant_info_fields": map[string]any{}, + "fetch_compliance_info": "true", + }}}, nil +} + +func buildCollectionsVariables(jid types.JID, params GetCollectionsParams) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if len(params.After) > 2048 { + return nil, fmt.Errorf("collection cursor exceeds 2048 bytes") + } + if params.CollectionLimit == 0 { + params.CollectionLimit = 20 + } + if params.CollectionLimit < 1 || params.CollectionLimit > 20 { + return nil, fmt.Errorf("collection limit must be between 1 and 20") + } + if params.ItemLimit == 0 { + params.ItemLimit = 50 + } + if params.ItemLimit < 1 || params.ItemLimit > 100 { + return nil, fmt.Errorf("collection item limit must be between 1 and 100") + } + width, height, err := normalizeDimensions(params.Width, params.Height) + if err != nil { + return nil, err + } + request := map[string]any{ + "biz_jid": jid.ToNonAD().String(), + "collection_limit": strconv.Itoa(params.CollectionLimit), + "item_limit": strconv.Itoa(params.ItemLimit), + "width": strconv.Itoa(width), + "height": strconv.Itoa(height), + "variant_thumbnail_width": strconv.Itoa(width), + "variant_thumbnail_height": strconv.Itoa(height), + "variant_info_fields": map[string]any{}, + } + if params.After != "" { + request["after"] = params.After + } + return map[string]any{"request": map[string]any{"collections": request}}, nil +} + +func buildSingleCollectionVariables(jid types.JID, collectionID string, params GetCatalogParams) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if err := validateBusinessID("collection", collectionID); err != nil { + return nil, err + } + if len(params.After) > 2048 { + return nil, fmt.Errorf("collection cursor exceeds 2048 bytes") + } + if params.Limit == 0 { + params.Limit = 50 + } + if params.Limit < 1 || params.Limit > 100 { + return nil, fmt.Errorf("collection item limit must be between 1 and 100") + } + width, height, err := normalizeDimensions(params.Width, params.Height) + if err != nil { + return nil, err + } + request := map[string]any{ + "biz_jid": jid.ToNonAD().String(), + "id": collectionID, + "limit": strconv.Itoa(params.Limit), + "width": strconv.Itoa(width), + "height": strconv.Itoa(height), + "variant_thumbnail_width": strconv.Itoa(width), + "variant_thumbnail_height": strconv.Itoa(height), + "variant_info_fields": map[string]any{}, + } + if params.After != "" { + request["after"] = params.After + } + return map[string]any{"request": map[string]any{"collection": request}}, nil +} + +func buildProductListVariables(jid types.JID, productIDs []string, width, height int) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if len(productIDs) < 1 || len(productIDs) > 100 { + return nil, fmt.Errorf("product list must contain between 1 and 100 IDs") + } + products := make([]map[string]any, len(productIDs)) + seen := make(map[string]struct{}, len(productIDs)) + for i, id := range productIDs { + if err := validateBusinessID("product", id); err != nil { + return nil, err + } + if _, exists := seen[id]; exists { + return nil, fmt.Errorf("duplicate product ID %q", id) + } + seen[id] = struct{}{} + products[i] = map[string]any{"id": id} + } + width, height, err := normalizeDimensions(width, height) + if err != nil { + return nil, err + } + return map[string]any{"request": map[string]any{"product_list": map[string]any{ + "jid": jid.ToNonAD().String(), + "products": products, + "width": strconv.Itoa(width), + "height": strconv.Itoa(height), + }}}, nil +} + +func normalizeDimensions(width, height int) (int, int, error) { + if width == 0 { + width = 100 + } + if height == 0 { + height = 100 + } + if width < 1 || width > 1024 || height < 1 || height > 1024 { + return 0, 0, fmt.Errorf("catalog image dimensions must be between 1 and 1024") + } + return width, height, nil +} + +func validateBusinessID(kind, id string) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("%s ID is empty", kind) + } + if len(id) > 256 { + return fmt.Errorf("%s ID exceeds 256 bytes", kind) + } + return nil +} + +func decodeCatalogProduct(data json.RawMessage) (*types.BusinessProduct, error) { + var response struct { + Result *struct { + Catalog *struct { + Product *types.BusinessProduct `json:"product"` + } `json:"product_catalog"` + } `json:"xwa_product_catalog_get_product"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("decode catalog product response: %w", err) + } + if response.Result == nil || response.Result.Catalog == nil || response.Result.Catalog.Product == nil { + return nil, fmt.Errorf("catalog product response is missing xwa_product_catalog_get_product.product_catalog.product") + } + return response.Result.Catalog.Product, nil +} + +func decodeCollections(data json.RawMessage) (*types.BusinessCollectionPage, error) { + var response struct { + Result *struct { + Collections []types.BusinessCollection `json:"collections"` + Paging *struct { + After string `json:"after"` + } `json:"paging"` + } `json:"xwa_product_catalog_get_collections"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("decode collections response: %w", err) + } + if response.Result == nil { + return nil, fmt.Errorf("collections response is missing xwa_product_catalog_get_collections") + } + page := &types.BusinessCollectionPage{Collections: response.Result.Collections} + if page.Collections == nil { + page.Collections = []types.BusinessCollection{} + } + if response.Result.Paging != nil { + page.Next = response.Result.Paging.After + } + return page, nil +} + +func decodeSingleCollection(data json.RawMessage) (*types.BusinessCollection, error) { + var response struct { + Result *struct { + Collection *types.BusinessCollection `json:"collection"` + } `json:"xwa_product_catalog_get_single_collection"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("decode collection response: %w", err) + } + if response.Result == nil || response.Result.Collection == nil { + return nil, fmt.Errorf("collection response is missing xwa_product_catalog_get_single_collection.collection") + } + if response.Result.Collection.Products == nil { + response.Result.Collection.Products = []types.BusinessProduct{} + } + return response.Result.Collection, nil +} + +func decodeProductList(data json.RawMessage, requested []string) ([]types.BusinessProduct, error) { + var response struct { + Result *struct { + List *struct { + Products []types.BusinessProduct `json:"products"` + } `json:"product_list"` + } `json:"xwa_product_catalog_get_product_list"` + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("decode product list response: %w", err) + } + if response.Result == nil || response.Result.List == nil { + return nil, fmt.Errorf("product list response is missing xwa_product_catalog_get_product_list.product_list") + } + byID := make(map[string]types.BusinessProduct, len(response.Result.List.Products)) + for _, product := range response.Result.List.Products { + if product.ID == "" { + return nil, fmt.Errorf("product list response contains an empty product ID") + } + if _, exists := byID[product.ID]; exists { + return nil, fmt.Errorf("product list response contains duplicate product ID %q", product.ID) + } + byID[product.ID] = product + } + products := make([]types.BusinessProduct, len(requested)) + for i, id := range requested { + product, ok := byID[id] + if !ok { + return nil, fmt.Errorf("product list response is missing requested product %q", id) + } + products[i] = product + } + return products, nil +} + +func (cli *Client) GetCatalog(ctx context.Context, business types.JID, params GetCatalogParams) (*types.BusinessCatalogPage, error) { + variables, err := buildCatalogVariables(business, params) + if err != nil { + return nil, err + } + data, err := cli.sendBusinessMex(ctx, mex.QueryCatalog, variables) + if err != nil { + return nil, err + } + return decodeCatalogPage(data) +} + +func (cli *Client) GetCatalogProduct(ctx context.Context, business types.JID, productID string) (*types.BusinessProduct, error) { + variables, err := buildCatalogProductVariables(business, productID, 100, 100) + if err != nil { + return nil, err + } + data, err := cli.sendBusinessMex(ctx, mex.QueryCatalogProduct, variables) + if err != nil { + return nil, err + } + return decodeCatalogProduct(data) +} + +func (cli *Client) GetProductCollections(ctx context.Context, business types.JID, params GetCollectionsParams) (*types.BusinessCollectionPage, error) { + variables, err := buildCollectionsVariables(business, params) + if err != nil { + return nil, err + } + data, err := cli.sendBusinessMex(ctx, mex.QueryProductCollections, variables) + if err != nil { + return nil, err + } + return decodeCollections(data) +} + +func (cli *Client) GetProductCollection(ctx context.Context, business types.JID, collectionID string, params GetCatalogParams) (*types.BusinessCollection, error) { + variables, err := buildSingleCollectionVariables(business, collectionID, params) + if err != nil { + return nil, err + } + data, err := cli.sendBusinessMex(ctx, mex.QueryProductSingleCollection, variables) + if err != nil { + return nil, err + } + return decodeSingleCollection(data) +} + +func (cli *Client) GetCatalogProducts(ctx context.Context, business types.JID, productIDs []string) ([]types.BusinessProduct, error) { + variables, err := buildProductListVariables(business, productIDs, 100, 100) + if err != nil { + return nil, err + } + data, err := cli.sendBusinessMex(ctx, mex.QueryProductListCatalog, variables) + if err != nil { + return nil, err + } + return decodeProductList(data, productIDs) +} + +func (cli *Client) sendBusinessMex(ctx context.Context, operationName mex.OperationName, variables map[string]any) (json.RawMessage, error) { + operation, ok := mex.Lookup(operationName) + if !ok { + return nil, fmt.Errorf("business MEX operation %q is not pinned", operationName) + } + data, err := cli.sendMexIQ(ctx, operation.DocumentID, variables) + if err != nil { + return nil, fmt.Errorf("%s: %w", operationName, err) + } + return data, nil +} diff --git a/business_catalog_test.go b/business_catalog_test.go new file mode 100644 index 000000000..bcb8a3b91 --- /dev/null +++ b/business_catalog_test.go @@ -0,0 +1,156 @@ +package whatsmeow + +import ( + "encoding/json" + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestBuildCatalogVariablesRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + jid types.JID + p GetCatalogParams + }{ + {"empty jid", types.EmptyJID, GetCatalogParams{}}, + {"group jid", types.NewJID("123", types.GroupServer), GetCatalogParams{}}, + {"limit too large", types.NewJID("123", types.DefaultUserServer), GetCatalogParams{Limit: 101}}, + {"negative width", types.NewJID("123", types.DefaultUserServer), GetCatalogParams{Width: -1}}, + {"height too large", types.NewJID("123", types.DefaultUserServer), GetCatalogParams{Height: 1025}}, + {"cursor too large", types.NewJID("123", types.DefaultUserServer), GetCatalogParams{After: string(make([]byte, 2049))}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := buildCatalogVariables(tc.jid, tc.p); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestDecodeCatalogPagePreservesCommerceFields(t *testing.T) { + raw := json.RawMessage(`{"xwa_product_catalog_get_product_catalog":{"product_catalog":{"paging":{"after":"next"},"products":[{"id":"p1","retailer_id":"sku-1","name":"Tea","description":"Green tea","price":"1250","currency":"USD","is_hidden":false,"is_sanctioned":false,"max_available":8,"product_availability":"in stock","media":{"images":[{"id":"i1","request_image_url":"https://synthetic.invalid/i1"}]},"status_info":{"can_appeal":true,"status":"APPROVED"}}]}}}`) + page, err := decodeCatalogPage(raw) + if err != nil { + t.Fatal(err) + } + if page.Next != "next" || len(page.Products) != 1 { + t.Fatalf("unexpected page: %#v", page) + } + product := page.Products[0] + if product.ID != "p1" || product.RetailerID != "sku-1" || product.Price != "1250" || product.Currency != "USD" || product.MaxAvailable != 8 { + t.Fatalf("unexpected product: %#v", product) + } + if len(product.Media.Images) != 1 || product.Media.Images[0].RequestURL != "https://synthetic.invalid/i1" || !product.Status.CanAppeal { + t.Fatalf("unexpected nested product fields: %#v", product) + } +} + +func TestDecodeCatalogPageFailsClosedWithoutDiscriminator(t *testing.T) { + if _, err := decodeCatalogPage(json.RawMessage(`{"unexpected":{}}`)); err == nil { + t.Fatal("expected response discriminator error") + } +} + +func TestBuildCatalogProductVariables(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + variables, err := buildCatalogProductVariables(jid, "p-tea", 0, 0) + if err != nil { + t.Fatal(err) + } + product := variables["request"].(map[string]any)["product"].(map[string]any) + if product["jid"] != jid.String() || product["product_id"] != "p-tea" || product["width"] != "100" || product["fetch_compliance_info"] != "true" { + t.Fatalf("unexpected variables: %#v", variables) + } + if _, err = buildCatalogProductVariables(jid, "", 100, 100); err == nil { + t.Fatal("expected empty product ID error") + } +} + +func TestDecodeCatalogProductRequiresProduct(t *testing.T) { + raw := json.RawMessage(`{"xwa_product_catalog_get_product":{"product_catalog":{"product":{"id":"p-tea","name":"Tea","price":"1250","currency":"USD"}}}}`) + product, err := decodeCatalogProduct(raw) + if err != nil { + t.Fatal(err) + } + if product.ID != "p-tea" || product.Price != "1250" { + t.Fatalf("unexpected product: %#v", product) + } + if _, err = decodeCatalogProduct(json.RawMessage(`{"xwa_product_catalog_get_product":{"product_catalog":{}}}`)); err == nil { + t.Fatal("expected missing product error") + } +} + +func TestBuildCollectionsVariablesAppliesIndependentBounds(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + variables, err := buildCollectionsVariables(jid, GetCollectionsParams{}) + if err != nil { + t.Fatal(err) + } + collections := variables["request"].(map[string]any)["collections"].(map[string]any) + if collections["biz_jid"] != jid.String() || collections["collection_limit"] != "20" || collections["item_limit"] != "50" { + t.Fatalf("unexpected variables: %#v", variables) + } + if _, err = buildCollectionsVariables(jid, GetCollectionsParams{CollectionLimit: 21}); err == nil { + t.Fatal("expected collection limit error") + } + if _, err = buildCollectionsVariables(jid, GetCollectionsParams{ItemLimit: 101}); err == nil { + t.Fatal("expected item limit error") + } +} + +func TestDecodeCollectionsPreservesCursorAndProducts(t *testing.T) { + raw := json.RawMessage(`{"xwa_product_catalog_get_collections":{"collections":[{"id":"c-summer","name":"Summer","products":[{"id":"p-tea","name":"Tea","price":"1250","currency":"USD"}],"status_info":{"status":"APPROVED","can_appeal":false}}],"paging":{"after":"next"}}}`) + page, err := decodeCollections(raw) + if err != nil { + t.Fatal(err) + } + if page.Next != "next" || len(page.Collections) != 1 || page.Collections[0].Products[0].ID != "p-tea" || page.Collections[0].Status.Status != "APPROVED" { + t.Fatalf("unexpected collections: %#v", page) + } +} + +func TestBuildSingleCollectionAndDecode(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + variables, err := buildSingleCollectionVariables(jid, "c-summer", GetCatalogParams{Limit: 10}) + if err != nil { + t.Fatal(err) + } + collectionRequest := variables["request"].(map[string]any)["collection"].(map[string]any) + if collectionRequest["biz_jid"] != jid.String() || collectionRequest["id"] != "c-summer" || collectionRequest["limit"] != "10" { + t.Fatalf("unexpected variables: %#v", variables) + } + raw := json.RawMessage(`{"xwa_product_catalog_get_single_collection":{"collection":{"id":"c-summer","name":"Summer","products":[]}}}`) + collection, err := decodeSingleCollection(raw) + if err != nil || collection.ID != "c-summer" { + t.Fatalf("collection = %#v, error = %v", collection, err) + } +} + +func TestProductListRejectsDuplicatesAndPreservesRequestedOrder(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + if _, err := buildProductListVariables(jid, []string{"p-tea", "p-tea"}, 100, 100); err == nil { + t.Fatal("expected duplicate product ID error") + } + raw := json.RawMessage(`{"xwa_product_catalog_get_product_list":{"product_list":{"products":[{"id":"p-coffee","name":"Coffee","price":"1400","currency":"USD"},{"id":"p-tea","name":"Tea","price":"1250","currency":"USD"}]}}}`) + products, err := decodeProductList(raw, []string{"p-tea", "p-coffee"}) + if err != nil { + t.Fatal(err) + } + if len(products) != 2 || products[0].ID != "p-tea" || products[1].ID != "p-coffee" { + t.Fatalf("unexpected product order: %#v", products) + } +} + +func TestBuildCatalogVariablesAppliesDefaults(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + variables, err := buildCatalogVariables(jid, GetCatalogParams{}) + if err != nil { + t.Fatal(err) + } + productCatalog := variables["request"].(map[string]any)["product_catalog"].(map[string]any) + if productCatalog["jid"] != jid.String() || productCatalog["limit"] != "50" || productCatalog["width"] != "100" || productCatalog["height"] != "100" { + t.Fatalf("unexpected variables: %#v", variables) + } +} diff --git a/types/business_catalog.go b/types/business_catalog.go new file mode 100644 index 000000000..a0c653bb0 --- /dev/null +++ b/types/business_catalog.go @@ -0,0 +1,141 @@ +package types + +type BusinessCatalogPage struct { + Next string `json:"next,omitempty"` + Previous string `json:"previous,omitempty"` + Products []BusinessProduct `json:"products"` +} + +type BusinessProduct struct { + ID string `json:"id"` + RetailerID string `json:"retailer_id,omitempty"` + BelongsTo string `json:"belongs_to,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Price string `json:"price"` + Currency string `json:"currency"` + URL string `json:"url,omitempty"` + ShimmedURL string `json:"shimmed_url,omitempty"` + Hidden bool `json:"is_hidden"` + Sanctioned bool `json:"is_sanctioned"` + MaxAvailable int `json:"max_available,omitempty"` + Availability string `json:"product_availability,omitempty"` + ComplianceCategory string `json:"compliance_category,omitempty"` + Compliance *BusinessComplianceInfo `json:"compliance_info,omitempty"` + Media BusinessProductMedia `json:"media"` + SalePrice *BusinessSalePrice `json:"sale_price,omitempty"` + Status BusinessProductStatus `json:"status_info"` + VariantInfo *BusinessProductVariant `json:"variant_info,omitempty"` +} + +type BusinessComplianceInfo struct { + CountryCodeOrigin string `json:"country_code_origin,omitempty"` + ImporterName string `json:"importer_name,omitempty"` + ImporterAddress *BusinessAddress `json:"importer_address,omitempty"` +} + +type BusinessAddress struct { + Street1 string `json:"street1,omitempty"` + Street2 string `json:"street2,omitempty"` + City string `json:"city,omitempty"` + Region string `json:"region,omitempty"` + PostalCode string `json:"postal_code,omitempty"` + CountryCode string `json:"country_code,omitempty"` +} + +type BusinessProductMedia struct { + Images []BusinessProductImage `json:"images,omitempty"` + Videos []BusinessProductVideo `json:"videos,omitempty"` +} + +type BusinessProductImage struct { + ID string `json:"id"` + OriginalURL string `json:"original_image_url,omitempty"` + RequestURL string `json:"request_image_url,omitempty"` +} + +type BusinessProductVideo struct { + ID string `json:"id"` + OriginalURL string `json:"original_video_url,omitempty"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` +} + +type BusinessSalePrice struct { + Price string `json:"price"` + StartDate string `json:"start_date,omitempty"` + EndDate string `json:"end_date,omitempty"` +} + +type BusinessProductStatus struct { + Status string `json:"status,omitempty"` + CanAppeal bool `json:"can_appeal,omitempty"` +} + +type BusinessProductVariant struct { + Availability BusinessVariantAvailability `json:"availability,omitempty"` + ListingDetails BusinessVariantListing `json:"listing_details,omitempty"` + Types []BusinessVariantType `json:"types,omitempty"` + VariantProperties []BusinessVariantProperty `json:"variant_properties,omitempty"` +} + +type BusinessVariantAvailability struct { + Listings []BusinessVariantAvailabilityItem `json:"listing,omitempty"` +} + +type BusinessVariantAvailabilityItem struct { + ProductID string `json:"product_id,omitempty"` + Available bool `json:"is_available"` + Options []BusinessVariantProperty `json:"options,omitempty"` +} + +type BusinessVariantListing struct { + Description string `json:"description,omitempty"` + LowestPrice string `json:"lowest_price,omitempty"` + MultiPrice string `json:"multi_price,omitempty"` +} + +type BusinessVariantType struct { + Name string `json:"name"` + Options []BusinessVariantOption `json:"options,omitempty"` +} + +type BusinessVariantOption struct { + Value string `json:"value"` + Thumbnail *BusinessVariantThumbnail `json:"thumbnail_media,omitempty"` +} + +type BusinessVariantThumbnail struct { + ID string `json:"id,omitempty"` + OriginalURL string `json:"original_image_url,omitempty"` + RequestURL string `json:"request_image_url,omitempty"` + OriginalDimensions BusinessDimensions `json:"original_dimensions,omitempty"` +} + +type BusinessDimensions struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +type BusinessVariantProperty struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type BusinessCollectionPage struct { + Next string `json:"next,omitempty"` + Collections []BusinessCollection `json:"collections"` +} + +type BusinessCollection struct { + ID string `json:"id"` + Name string `json:"name"` + Products []BusinessProduct `json:"products"` + Status BusinessCollectionStatus `json:"status_info"` +} + +type BusinessCollectionStatus struct { + Status string `json:"status,omitempty"` + CanAppeal bool `json:"can_appeal,omitempty"` + CommerceURL string `json:"commerce_url,omitempty"` + RejectReason string `json:"reject_reason,omitempty"` +} From c254d1c227cb9aa63fdff66eaa0cfaeab534db33 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 04:14:24 +0300 Subject: [PATCH 003/163] fix: harden Business App order reads --- business.go | 79 +++++++++++++++++++++++++++++++++++----- business_catalog_test.go | 43 ++++++++++++++++++++++ 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/business.go b/business.go index 6291b944f..d3cd4e7ed 100644 --- a/business.go +++ b/business.go @@ -10,6 +10,7 @@ import ( "context" "fmt" "strconv" + "strings" waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/types" @@ -18,6 +19,9 @@ import ( // GetOrderDetails fetches the details of a specific order using its ID and token. // Both token and orderID are found in the OrderMessage. func (cli *Client) GetOrderDetails(ctx context.Context, orderID, tokenBase64 string) (*types.OrderDetails, error) { + if err := validateOrderLookup(orderID, tokenBase64); err != nil { + return nil, err + } resp, err := cli.sendIQ(ctx, infoQuery{ Namespace: "fb:thrift_iq", Type: iqGet, @@ -50,7 +54,37 @@ func (cli *Client) GetOrderDetails(ctx context.Context, orderID, tokenBase64 str return nil, &ElementMissingError{Tag: "order", In: "response to order query"} } - return parseOrderDetailsNode(orderNode) + details, err := parseOrderDetailsNode(orderNode) + if err != nil { + return nil, err + } + if err = validateOrderResponseID(orderID, details.ID); err != nil { + return nil, err + } + return details, nil +} + +func validateOrderLookup(orderID, token string) error { + if strings.TrimSpace(orderID) == "" { + return fmt.Errorf("order ID is empty") + } + if len(orderID) > 256 { + return fmt.Errorf("order ID exceeds 256 bytes") + } + if strings.TrimSpace(token) == "" { + return fmt.Errorf("order token is empty") + } + if len(token) > 8192 { + return fmt.Errorf("order token exceeds 8192 bytes") + } + return nil +} + +func validateOrderResponseID(requested, returned string) error { + if returned != requested { + return fmt.Errorf("order response ID %q does not match requested ID %q", returned, requested) + } + return nil } // Helper to get the string content of a child node. @@ -73,11 +107,16 @@ func parseOrderDetailsNode(orderNode waBinary.Node) (*types.OrderDetails, error) return nil, err } - // Parse Price priceNode, ok := orderNode.GetOptionalChildByTag("price") if ok { - subtotal, _ := strconv.ParseInt(getStringChild(priceNode, "subtotal"), 10, 64) - total, _ := strconv.ParseInt(getStringChild(priceNode, "total"), 10, 64) + subtotal, err := parseInt64Child(priceNode, "subtotal") + if err != nil { + return nil, err + } + total, err := parseInt64Child(priceNode, "total") + if err != nil { + return nil, err + } details.Price = types.OrderPrice{ Subtotal: subtotal, Total: total, @@ -86,16 +125,20 @@ func parseOrderDetailsNode(orderNode waBinary.Node) (*types.OrderDetails, error) } } - // Parse Catalog ID catalogNode, ok := orderNode.GetOptionalChildByTag("catalog") if ok { details.CatalogID = getStringChild(catalogNode, "id") } - // Parse Products for _, productNode := range orderNode.GetChildrenByTag("product") { - price, _ := strconv.ParseInt(getStringChild(productNode, "price"), 10, 64) - quantity, _ := strconv.Atoi(getStringChild(productNode, "quantity")) + price, err := parseInt64Child(productNode, "price") + if err != nil { + return nil, err + } + quantity, err := parseIntChild(productNode, "quantity") + if err != nil { + return nil, err + } product := types.OrderProduct{ ID: getStringChild(productNode, "id"), @@ -105,13 +148,11 @@ func parseOrderDetailsNode(orderNode waBinary.Node) (*types.OrderDetails, error) Quantity: quantity, } - // Parse Product Image if imageNode, ok := productNode.GetOptionalChildByTag("image"); ok { product.ImageID = getStringChild(imageNode, "id") product.ImageURL = getStringChild(imageNode, "url") } - // Parse Variant Info if variantNode, ok := productNode.GetOptionalChildByTag("variant_info"); ok { product.VariantInfo.Properties = getStringChild(variantNode, "properties") } @@ -121,3 +162,21 @@ func parseOrderDetailsNode(orderNode waBinary.Node) (*types.OrderDetails, error) return details, nil } + +func parseInt64Child(node waBinary.Node, tag string) (int64, error) { + raw := getStringChild(node, tag) + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %s value %q: %w", tag, raw, err) + } + return value, nil +} + +func parseIntChild(node waBinary.Node, tag string) (int, error) { + raw := getStringChild(node, tag) + value, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("invalid %s value %q: %w", tag, raw, err) + } + return value, nil +} diff --git a/business_catalog_test.go b/business_catalog_test.go index bcb8a3b91..74821112d 100644 --- a/business_catalog_test.go +++ b/business_catalog_test.go @@ -2,8 +2,10 @@ package whatsmeow import ( "encoding/json" + "strings" "testing" + waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/types" ) @@ -143,6 +145,47 @@ func TestProductListRejectsDuplicatesAndPreservesRequestedOrder(t *testing.T) { } } +func TestParseOrderDetailsRejectsMalformedMoney(t *testing.T) { + node := waBinary.Node{ + Tag: "order", + Attrs: waBinary.Attrs{"id": "o-100", "creation_ts": "1"}, + Content: []waBinary.Node{{ + Tag: "price", + Content: []waBinary.Node{ + {Tag: "subtotal", Content: []byte("1250")}, + {Tag: "total", Content: []byte("not-a-number")}, + {Tag: "currency", Content: []byte("USD")}, + }, + }}, + } + if _, err := parseOrderDetailsNode(node); err == nil { + t.Fatal("expected malformed total error") + } +} + +func TestValidateOrderLookupBounds(t *testing.T) { + tests := []struct { + orderID string + token string + }{ + {"", "token"}, + {"o-100", ""}, + {strings.Repeat("o", 257), "token"}, + {"o-100", strings.Repeat("x", 8193)}, + } + for _, tc := range tests { + if err := validateOrderLookup(tc.orderID, tc.token); err == nil { + t.Fatalf("validateOrderLookup(%d-byte ID, %d-byte token) unexpectedly passed", len(tc.orderID), len(tc.token)) + } + } +} + +func TestValidateOrderResponseIDRejectsDifferentOrder(t *testing.T) { + if err := validateOrderResponseID("o-100", "o-101"); err == nil { + t.Fatal("expected mismatched order ID error") + } +} + func TestBuildCatalogVariablesAppliesDefaults(t *testing.T) { jid := types.NewJID("15551234567", types.DefaultUserServer) variables, err := buildCatalogVariables(jid, GetCatalogParams{}) From 1d5619dd3dc6f3348f6b246fe4a9c07efa462ae9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 04:42:58 +0300 Subject: [PATCH 004/163] test: validate Business App reads through Barback --- benchmark/barback/README.md | 2 + benchmark/barback/cmd/bench/main.go | 65 ++++++++++++++++++++++++ benchmark/barback/cmd/bench/main_test.go | 4 ++ benchmark/barback/compose.yaml | 1 + 4 files changed, 72 insertions(+) diff --git a/benchmark/barback/README.md b/benchmark/barback/README.md index 2b346e88a..e185566bb 100644 --- a/benchmark/barback/README.md +++ b/benchmark/barback/README.md @@ -76,3 +76,5 @@ The frozen three-repeat comparison is summarized in `results/system-comparison.m Set `MEM_PROFILE_PATH=/results/run.heap.pb.gz` to capture a full-rate Go allocation profile. Profiling changes runtime cost, so compare profiles with each other rather than with ordinary benchmark timings. Inspect cumulative allocations with `go tool pprof -top -alloc_space results/run.heap.pb.gz` and retained heap with `go tool pprof -top -inuse_space results/run.heap.pb.gz`. Change only one workload dimension at a time. Recommended group sizes are 32, 128, 512, and 1024. Keep the Barback revision, mode, sender count, rate, total, history size, container limits, host power state, and Docker version fixed across a baseline/candidate pair. + +Set `BENCH_BUSINESS_SMOKE=true` to validate live catalog, product, collection, product-list, and order reads through the paired HyperMeow connection before accepting the benchmark result. The fixtures are generated by Barback and never contact a WhatsApp account. diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 00d3cf86d..829e4f676 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -42,6 +42,7 @@ type config struct { OutputPath string MemProfilePath string Variant string + BusinessSmoke bool Total int64 Timeout time.Duration Workload workloadConfig @@ -123,6 +124,7 @@ type result struct { MediaUploads int64 `json:"media_uploads"` MediaUploadBytes int64 `json:"media_upload_bytes"` MediaUploadLatency latencyStats `json:"media_upload_latency"` + BusinessAppValidated bool `json:"business_app_validated"` } type runner struct { @@ -140,6 +142,7 @@ type runner struct { messageSequence atomic.Int64 mediaUploads atomic.Int64 mediaBytes atomic.Int64 + businessValid atomic.Bool startOnce sync.Once doneOnce sync.Once @@ -255,6 +258,10 @@ func loadConfig() (config, error) { if err != nil { return config{}, err } + businessSmoke, err := strconv.ParseBool(env("BENCH_BUSINESS_SMOKE", "false")) + if err != nil { + return config{}, fmt.Errorf("invalid BENCH_BUSINESS_SMOKE") + } return config{ DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@postgres:5432/hypermeow?sslmode=disable"), BarbackURL: env("BARBACK_URL", "http://barback:8080"), @@ -264,6 +271,7 @@ func loadConfig() (config, error) { OutputPath: env("RESULT_PATH", "/results/result.json"), MemProfilePath: os.Getenv("MEM_PROFILE_PATH"), Variant: env("BENCH_VARIANT", "candidate"), + BusinessSmoke: businessSmoke, Total: total, Timeout: timeout, Workload: workloadConfig{ @@ -375,6 +383,15 @@ func (r *runner) run() (result, error) { return r.snapshot(false), fmt.Errorf("connect: %w", err) } defer client.Disconnect() + if r.cfg.BusinessSmoke { + if !client.WaitForConnection(30 * time.Second) { + return r.snapshot(false), fmt.Errorf("business app validation: connection did not become ready") + } + if err = validateBusinessApp(ctx, client); err != nil { + return r.snapshot(false), err + } + r.businessValid.Store(true) + } select { case <-r.done: @@ -386,6 +403,53 @@ func (r *runner) run() (result, error) { } } +func validateBusinessApp(ctx context.Context, client *whatsmeow.Client) error { + business := types.NewJID("15551234567", types.DefaultUserServer) + catalog, err := client.GetCatalog(ctx, business, whatsmeow.GetCatalogParams{Limit: 50}) + if err != nil { + return fmt.Errorf("business app catalog: %w", err) + } + if len(catalog.Products) != 2 || catalog.Products[0].ID != "p-tea" || catalog.Products[0].Price != "1250" || catalog.Products[0].Currency != "USD" { + return fmt.Errorf("business app catalog returned an unexpected fixture") + } + product, err := client.GetCatalogProduct(ctx, business, "p-tea") + if err != nil { + return fmt.Errorf("business app product: %w", err) + } + if product.ID != "p-tea" || product.RetailerID != "sku-tea-20" { + return fmt.Errorf("business app product returned an unexpected fixture") + } + collections, err := client.GetProductCollections(ctx, business, whatsmeow.GetCollectionsParams{}) + if err != nil { + return fmt.Errorf("business app collections: %w", err) + } + if len(collections.Collections) != 1 || collections.Collections[0].ID != "c-seasonal" { + return fmt.Errorf("business app collections returned an unexpected fixture") + } + collection, err := client.GetProductCollection(ctx, business, "c-seasonal", whatsmeow.GetCatalogParams{Limit: 50}) + if err != nil { + return fmt.Errorf("business app collection: %w", err) + } + if collection.ID != "c-seasonal" || len(collection.Products) != 2 { + return fmt.Errorf("business app collection returned an unexpected fixture") + } + products, err := client.GetCatalogProducts(ctx, business, []string{"p-coffee", "p-tea"}) + if err != nil { + return fmt.Errorf("business app product list: %w", err) + } + if len(products) != 2 || products[0].ID != "p-coffee" || products[1].ID != "p-tea" { + return fmt.Errorf("business app product list returned an unexpected fixture") + } + order, err := client.GetOrderDetails(ctx, "o-100", "synthetic-token") + if err != nil { + return fmt.Errorf("business app order: %w", err) + } + if order.ID != "o-100" || order.Price.Total != 2650 || len(order.Products) != 2 { + return fmt.Errorf("business app order returned an unexpected fixture") + } + return nil +} + func (r *runner) stopMetricsSampler() { r.metricsMu.Lock() if r.tempStop != nil { @@ -594,6 +658,7 @@ func (r *runner) snapshot(completed bool) result { MediaUploads: r.mediaUploads.Load(), MediaUploadBytes: r.mediaBytes.Load(), MediaUploadLatency: r.uploadLatencySnapshot(), + BusinessAppValidated: r.businessValid.Load(), } r.metricsMu.Lock() if r.sessionStarted { diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 321984388..05424f7f5 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -20,6 +20,7 @@ func TestLoadConfigWorkload(t *testing.T) { t.Setenv("BENCH_GROUP_SIZE", "0") t.Setenv("HISTORY_CONVERSATIONS", "10") t.Setenv("HISTORY_MESSAGES", "5") + t.Setenv("BENCH_BUSINESS_SMOKE", "1") cfg, err := loadConfig() if err != nil { @@ -34,6 +35,9 @@ func TestLoadConfigWorkload(t *testing.T) { if cfg.Workload.HistoryConversations != 10 || cfg.Workload.HistoryMessages != 5 { t.Fatalf("unexpected history workload: %+v", cfg.Workload) } + if !cfg.BusinessSmoke { + t.Fatal("business smoke validation was not enabled") + } } func TestLoadConfigRejectsInvalidWorkload(t *testing.T) { diff --git a/benchmark/barback/compose.yaml b/benchmark/barback/compose.yaml index 5ad0f9698..f00c0f003 100644 --- a/benchmark/barback/compose.yaml +++ b/benchmark/barback/compose.yaml @@ -110,6 +110,7 @@ services: BENCH_TOTAL: ${BENCH_TOTAL:-200} BENCH_VARIANT: ${BENCH_VARIANT:-candidate} BENCH_GROUP_SIZE: ${BENCH_GROUP_SIZE:-128} + BENCH_BUSINESS_SMOKE: ${BENCH_BUSINESS_SMOKE:-false} BENCH_MODE: ${BENCH_MODE:-group} BENCH_MESSAGE_PROFILE: ${BENCH_MESSAGE_PROFILE:-text} BENCH_RATE: ${BENCH_RATE:-50} From 92aa3becaddcf98db5b15fa60de309147a00b47d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 04:53:41 +0300 Subject: [PATCH 005/163] test: wait for authenticated Barback session --- benchmark/barback/cmd/bench/main.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 829e4f676..e0d0bf94c 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -166,6 +166,8 @@ type runner struct { tempStop chan struct{} tempPeakBytes atomic.Int64 tempPeakFiles atomic.Int64 + connected chan struct{} + connectedOnce sync.Once } func main() { @@ -189,6 +191,7 @@ func main() { latencies: make([]float64, 0, cfg.Total), messageTypes: make(map[string]int64), failureReasons: make(map[string]int64), + connected: make(chan struct{}), } for i := range r.jobs { r.jobs[i] = make(chan *events.Message, queueCapacity) @@ -384,8 +387,14 @@ func (r *runner) run() (result, error) { } defer client.Disconnect() if r.cfg.BusinessSmoke { - if !client.WaitForConnection(30 * time.Second) { + connectionTimer := time.NewTimer(30 * time.Second) + defer connectionTimer.Stop() + select { + case <-r.connected: + case <-connectionTimer.C: return r.snapshot(false), fmt.Errorf("business app validation: connection did not become ready") + case <-ctx.Done(): + return r.snapshot(false), fmt.Errorf("business app validation: connection did not become ready: %w", ctx.Err()) } if err = validateBusinessApp(ctx, client); err != nil { return r.snapshot(false), err @@ -468,6 +477,11 @@ func (r *runner) handler(client *whatsmeow.Client) whatsmeow.EventHandler { scanOnce.Do(func() { go r.scanQR(event.Codes[0]) }) } case *events.Connected: + r.connectedOnce.Do(func() { + if r.connected != nil { + close(r.connected) + } + }) if _, err := r.db.ExecContext(context.Background(), "SELECT pg_stat_statements_reset()"); err != nil { fmt.Fprintf(os.Stderr, "reset statement stats: %v\n", err) } From ada407a4586c4c8a3af5c33d601e87554b281445 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 00:40:27 +0300 Subject: [PATCH 006/163] build: make repository pre-commit clean --- .pre-commit-config.yaml | 1 + benchmark/barback/cmd/bench/main.go | 1 + benchmark/barback/cmd/bench/workload_messages.go | 3 ++- benchmark/barback/go.mod | 3 --- benchmark/barback/go.sum | 12 ------------ .../barback/patches/barback-socket-config.patch | 8 ++++---- store/sessioncache_test.go | 1 + 7 files changed, 9 insertions(+), 20 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bec963f3c..59fb92424 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,6 +8,7 @@ repos: - id: end-of-file-fixer exclude: LICENSE - id: check-yaml + exclude: ^benchmark/barback/compose\.(dm|local-cache)\.yaml$ - id: check-added-large-files - repo: https://github.com/tekwizely/pre-commit-golang diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index e0d0bf94c..b93864574 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -24,6 +24,7 @@ import ( "time" _ "github.com/jackc/pgx/v5/stdlib" + "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" diff --git a/benchmark/barback/cmd/bench/workload_messages.go b/benchmark/barback/cmd/bench/workload_messages.go index 6c7fab557..43183f733 100644 --- a/benchmark/barback/cmd/bench/workload_messages.go +++ b/benchmark/barback/cmd/bench/workload_messages.go @@ -7,10 +7,11 @@ import ( "sync" "time" + "google.golang.org/protobuf/proto" + "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waCommon" "go.mau.fi/whatsmeow/proto/waE2E" - "google.golang.org/protobuf/proto" ) var ( diff --git a/benchmark/barback/go.mod b/benchmark/barback/go.mod index ea1043693..565e97c84 100644 --- a/benchmark/barback/go.mod +++ b/benchmark/barback/go.mod @@ -12,9 +12,7 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect - github.com/beeper/argo-go v1.1.2 // indirect github.com/coder/websocket v1.8.15 // indirect - github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -24,7 +22,6 @@ require ( github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca // indirect github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11 // indirect github.com/rs/zerolog v1.35.1 // indirect - github.com/vektah/gqlparser/v2 v2.5.27 // indirect go.mau.fi/util v0.9.12-0.20260717235539-f9ffa7eca58d // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect diff --git a/benchmark/barback/go.sum b/benchmark/barback/go.sum index adccd192f..c38bccef7 100644 --- a/benchmark/barback/go.sum +++ b/benchmark/barback/go.sum @@ -2,19 +2,11 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= -github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= -github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= -github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -41,15 +33,11 @@ github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11 github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11/go.mod h1:B5KZPsgswdf1eCvC+PZ7z45g5OECgqy6Zb0o7I5CcNQ= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= -github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= go.mau.fi/util v0.9.12-0.20260717235539-f9ffa7eca58d h1:bUGRidYrauEwLoBvGLLPPQ0lgQUecT0z/ND+vHz07jM= go.mau.fi/util v0.9.12-0.20260717235539-f9ffa7eca58d/go.mod h1:xunp/oIQfFD68HHcNHfG0pOiHkvEtDhTweeIwKJ//+Q= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= diff --git a/benchmark/barback/patches/barback-socket-config.patch b/benchmark/barback/patches/barback-socket-config.patch index eaa6d654c..6a2dd525a 100644 --- a/benchmark/barback/patches/barback-socket-config.patch +++ b/benchmark/barback/patches/barback-socket-config.patch @@ -9,11 +9,11 @@ index 313970d..17a33c3 100644 + SocketConfig *SocketConfig RefreshCAT func(context.Context) error } - + @@ -221,6 +222,13 @@ type MessengerConfig struct { WebsocketURL string } - + +// SocketConfig overrides the WebSocket endpoint or Noise certificate authority. +type SocketConfig struct { + URL string @@ -56,11 +56,11 @@ index 8badf1a..8f9d86c 100644 + if err = verifyServerCert(certDecrypted, staticDecrypted, certAuthority); err != nil { return fmt.Errorf("failed to verify server cert: %w", err) } - + @@ -140,7 +145,7 @@ func checkCertValidity(cert *waCert.CertChain_NoiseCertificate_Details) error { return nil } - + -func verifyServerCert(certDecrypted, staticDecrypted []byte) error { +func verifyServerCert(certDecrypted, staticDecrypted []byte, certAuthority [32]byte) error { var certChain waCert.CertChain diff --git a/store/sessioncache_test.go b/store/sessioncache_test.go index cab589a38..5ce984737 100644 --- a/store/sessioncache_test.go +++ b/store/sessioncache_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/polymorfa/libsignal-protocol-go/protocol" + "go.mau.fi/whatsmeow/types" ) From 26dd4182cc715e5cbda082e7aa81b2bae53a26c1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:15:46 +0300 Subject: [PATCH 007/163] fix: preserve collection paging metadata --- business_catalog.go | 10 +++++++++- business_catalog_test.go | 6 ++++-- types/business_catalog.go | 2 ++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/business_catalog.go b/business_catalog.go index 27b8fe387..409b910cc 100644 --- a/business_catalog.go +++ b/business_catalog.go @@ -90,7 +90,7 @@ func buildCatalogVariables(jid types.JID, params GetCatalogParams) (map[string]a } func validateBusinessJID(jid types.JID) error { - if jid.IsEmpty() { + if jid.IsEmpty() || jid.User == "" { return fmt.Errorf("business JID is empty") } if jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer { @@ -297,6 +297,10 @@ func decodeSingleCollection(data json.RawMessage) (*types.BusinessCollection, er var response struct { Result *struct { Collection *types.BusinessCollection `json:"collection"` + Paging *struct { + After string `json:"after"` + Before string `json:"before"` + } `json:"paging"` } `json:"xwa_product_catalog_get_single_collection"` } if err := json.Unmarshal(data, &response); err != nil { @@ -308,6 +312,10 @@ func decodeSingleCollection(data json.RawMessage) (*types.BusinessCollection, er if response.Result.Collection.Products == nil { response.Result.Collection.Products = []types.BusinessProduct{} } + if response.Result.Paging != nil { + response.Result.Collection.Next = response.Result.Paging.After + response.Result.Collection.Previous = response.Result.Paging.Before + } return response.Result.Collection, nil } diff --git a/business_catalog_test.go b/business_catalog_test.go index 74821112d..213b17c2c 100644 --- a/business_catalog_test.go +++ b/business_catalog_test.go @@ -16,6 +16,8 @@ func TestBuildCatalogVariablesRejectsInvalidInput(t *testing.T) { p GetCatalogParams }{ {"empty jid", types.EmptyJID, GetCatalogParams{}}, + {"server jid", types.ServerJID, GetCatalogParams{}}, + {"empty user jid", types.NewJID("", types.DefaultUserServer), GetCatalogParams{}}, {"group jid", types.NewJID("123", types.GroupServer), GetCatalogParams{}}, {"limit too large", types.NewJID("123", types.DefaultUserServer), GetCatalogParams{Limit: 101}}, {"negative width", types.NewJID("123", types.DefaultUserServer), GetCatalogParams{Width: -1}}, @@ -123,9 +125,9 @@ func TestBuildSingleCollectionAndDecode(t *testing.T) { if collectionRequest["biz_jid"] != jid.String() || collectionRequest["id"] != "c-summer" || collectionRequest["limit"] != "10" { t.Fatalf("unexpected variables: %#v", variables) } - raw := json.RawMessage(`{"xwa_product_catalog_get_single_collection":{"collection":{"id":"c-summer","name":"Summer","products":[]}}}`) + raw := json.RawMessage(`{"xwa_product_catalog_get_single_collection":{"collection":{"id":"c-summer","name":"Summer","products":[]},"paging":{"after":"next","before":"previous"}}}`) collection, err := decodeSingleCollection(raw) - if err != nil || collection.ID != "c-summer" { + if err != nil || collection.ID != "c-summer" || collection.Next != "next" || collection.Previous != "previous" { t.Fatalf("collection = %#v, error = %v", collection, err) } } diff --git a/types/business_catalog.go b/types/business_catalog.go index a0c653bb0..df68dc30f 100644 --- a/types/business_catalog.go +++ b/types/business_catalog.go @@ -129,6 +129,8 @@ type BusinessCollectionPage struct { type BusinessCollection struct { ID string `json:"id"` Name string `json:"name"` + Next string `json:"next,omitempty"` + Previous string `json:"previous,omitempty"` Products []BusinessProduct `json:"products"` Status BusinessCollectionStatus `json:"status_info"` } From c765b54cad59d2e37b516dea7309d1a427b7ef73 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:22:14 +0300 Subject: [PATCH 008/163] fix: build benchmark against baseline libraries --- benchmark/barback/Dockerfile | 3 +- .../barback/cmd/bench/business_app_smoke.go | 58 +++++++++++++++++++ .../cmd/bench/business_app_smoke_legacy.go | 14 +++++ benchmark/barback/cmd/bench/main.go | 47 --------------- benchmark/barback/compose.yaml | 1 + benchmark/barback/run-comparison-matrix.sh | 6 +- 6 files changed, 80 insertions(+), 49 deletions(-) create mode 100644 benchmark/barback/cmd/bench/business_app_smoke.go create mode 100644 benchmark/barback/cmd/bench/business_app_smoke_legacy.go diff --git a/benchmark/barback/Dockerfile b/benchmark/barback/Dockerfile index 2a6008ca1..b727330fe 100644 --- a/benchmark/barback/Dockerfile +++ b/benchmark/barback/Dockerfile @@ -2,6 +2,7 @@ FROM golang:1.26-bookworm@sha256:6c5605ab3a9a9fb3c4eafe5b3d63cdbf3881caf113262b67862547b54a9db599 AS build ARG BUILD_REV=working-tree +ARG BENCH_BUILD_TAGS WORKDIR /src COPY . . COPY --from=library . /library @@ -9,7 +10,7 @@ WORKDIR /src/benchmark/barback RUN go mod edit -replace=go.mau.fi/whatsmeow=/library RUN --mount=type=cache,target=/go/pkg/mod go mod tidy RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.revision=${BUILD_REV}" -o /out/hypermeow-bench ./cmd/bench + CGO_ENABLED=0 go build -tags="${BENCH_BUILD_TAGS}" -trimpath -ldflags="-s -w -X main.revision=${BUILD_REV}" -o /out/hypermeow-bench ./cmd/bench FROM gcr.io/distroless/static-debian12:nonroot@sha256:f5b485ea962d9bd1186b2f6b3a061191539b905b82ec395de78cbfae51f20e35 COPY --from=build /out/hypermeow-bench /usr/local/bin/hypermeow-bench diff --git a/benchmark/barback/cmd/bench/business_app_smoke.go b/benchmark/barback/cmd/bench/business_app_smoke.go new file mode 100644 index 000000000..c1b78b088 --- /dev/null +++ b/benchmark/barback/cmd/bench/business_app_smoke.go @@ -0,0 +1,58 @@ +//go:build !benchmark_legacy + +package main + +import ( + "context" + "fmt" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" +) + +func validateBusinessApp(ctx context.Context, client *whatsmeow.Client) error { + business := types.NewJID("15551234567", types.DefaultUserServer) + catalog, err := client.GetCatalog(ctx, business, whatsmeow.GetCatalogParams{Limit: 50}) + if err != nil { + return fmt.Errorf("business app catalog: %w", err) + } + if len(catalog.Products) != 2 || catalog.Products[0].ID != "p-tea" || catalog.Products[0].Price != "1250" || catalog.Products[0].Currency != "USD" { + return fmt.Errorf("business app catalog returned an unexpected fixture") + } + product, err := client.GetCatalogProduct(ctx, business, "p-tea") + if err != nil { + return fmt.Errorf("business app product: %w", err) + } + if product.ID != "p-tea" || product.RetailerID != "sku-tea-20" { + return fmt.Errorf("business app product returned an unexpected fixture") + } + collections, err := client.GetProductCollections(ctx, business, whatsmeow.GetCollectionsParams{}) + if err != nil { + return fmt.Errorf("business app collections: %w", err) + } + if len(collections.Collections) != 1 || collections.Collections[0].ID != "c-seasonal" { + return fmt.Errorf("business app collections returned an unexpected fixture") + } + collection, err := client.GetProductCollection(ctx, business, "c-seasonal", whatsmeow.GetCatalogParams{Limit: 50}) + if err != nil { + return fmt.Errorf("business app collection: %w", err) + } + if collection.ID != "c-seasonal" || len(collection.Products) != 2 { + return fmt.Errorf("business app collection returned an unexpected fixture") + } + products, err := client.GetCatalogProducts(ctx, business, []string{"p-coffee", "p-tea"}) + if err != nil { + return fmt.Errorf("business app product list: %w", err) + } + if len(products) != 2 || products[0].ID != "p-coffee" || products[1].ID != "p-tea" { + return fmt.Errorf("business app product list returned an unexpected fixture") + } + order, err := client.GetOrderDetails(ctx, "o-100", "synthetic-token") + if err != nil { + return fmt.Errorf("business app order: %w", err) + } + if order.ID != "o-100" || order.Price.Total != 2650 || len(order.Products) != 2 { + return fmt.Errorf("business app order returned an unexpected fixture") + } + return nil +} diff --git a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go new file mode 100644 index 000000000..a2db84043 --- /dev/null +++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go @@ -0,0 +1,14 @@ +//go:build benchmark_legacy + +package main + +import ( + "context" + "fmt" + + "go.mau.fi/whatsmeow" +) + +func validateBusinessApp(context.Context, *whatsmeow.Client) error { + return fmt.Errorf("business app validation requires HyperMeow") +} diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index b93864574..dd24eeecb 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -413,53 +413,6 @@ func (r *runner) run() (result, error) { } } -func validateBusinessApp(ctx context.Context, client *whatsmeow.Client) error { - business := types.NewJID("15551234567", types.DefaultUserServer) - catalog, err := client.GetCatalog(ctx, business, whatsmeow.GetCatalogParams{Limit: 50}) - if err != nil { - return fmt.Errorf("business app catalog: %w", err) - } - if len(catalog.Products) != 2 || catalog.Products[0].ID != "p-tea" || catalog.Products[0].Price != "1250" || catalog.Products[0].Currency != "USD" { - return fmt.Errorf("business app catalog returned an unexpected fixture") - } - product, err := client.GetCatalogProduct(ctx, business, "p-tea") - if err != nil { - return fmt.Errorf("business app product: %w", err) - } - if product.ID != "p-tea" || product.RetailerID != "sku-tea-20" { - return fmt.Errorf("business app product returned an unexpected fixture") - } - collections, err := client.GetProductCollections(ctx, business, whatsmeow.GetCollectionsParams{}) - if err != nil { - return fmt.Errorf("business app collections: %w", err) - } - if len(collections.Collections) != 1 || collections.Collections[0].ID != "c-seasonal" { - return fmt.Errorf("business app collections returned an unexpected fixture") - } - collection, err := client.GetProductCollection(ctx, business, "c-seasonal", whatsmeow.GetCatalogParams{Limit: 50}) - if err != nil { - return fmt.Errorf("business app collection: %w", err) - } - if collection.ID != "c-seasonal" || len(collection.Products) != 2 { - return fmt.Errorf("business app collection returned an unexpected fixture") - } - products, err := client.GetCatalogProducts(ctx, business, []string{"p-coffee", "p-tea"}) - if err != nil { - return fmt.Errorf("business app product list: %w", err) - } - if len(products) != 2 || products[0].ID != "p-coffee" || products[1].ID != "p-tea" { - return fmt.Errorf("business app product list returned an unexpected fixture") - } - order, err := client.GetOrderDetails(ctx, "o-100", "synthetic-token") - if err != nil { - return fmt.Errorf("business app order: %w", err) - } - if order.ID != "o-100" || order.Price.Total != 2650 || len(order.Products) != 2 { - return fmt.Errorf("business app order returned an unexpected fixture") - } - return nil -} - func (r *runner) stopMetricsSampler() { r.metricsMu.Lock() if r.tempStop != nil { diff --git a/benchmark/barback/compose.yaml b/benchmark/barback/compose.yaml index f00c0f003..8741dce3c 100644 --- a/benchmark/barback/compose.yaml +++ b/benchmark/barback/compose.yaml @@ -95,6 +95,7 @@ services: additional_contexts: library: ${LIBRARY_CONTEXT:-../..} args: + BENCH_BUILD_TAGS: ${BENCH_BUILD_TAGS:-} BUILD_REV: ${BUILD_REV:-working-tree} depends_on: barback: diff --git a/benchmark/barback/run-comparison-matrix.sh b/benchmark/barback/run-comparison-matrix.sh index 53da70a5d..6757b8a03 100755 --- a/benchmark/barback/run-comparison-matrix.sh +++ b/benchmark/barback/run-comparison-matrix.sh @@ -42,10 +42,14 @@ fi run_revision() { local name=$1 context=$2 revision=$3 repeat=$4 scenario=$5 variant=$1 + local build_tags= + if [[ $name != hypermeow ]]; then + build_tags=benchmark_legacy + fi if ((repeats > 1)); then variant="${name}-r${repeat}" fi - LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="$variant" ./run-system-matrix.sh "$scenario" + LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="$variant" BENCH_BUILD_TAGS="$build_tags" ./run-system-matrix.sh "$scenario" } for ((repeat = repeat_start; repeat <= repeats; repeat++)); do From 5fef1e9915afa29ca94b2bcb3bf3bcddabaced5a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 04:15:22 +0300 Subject: [PATCH 009/163] fix: detect legacy benchmark candidates --- benchmark/barback/README.md | 2 +- benchmark/barback/run-comparison-matrix.sh | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/benchmark/barback/README.md b/benchmark/barback/README.md index e185566bb..b757f7d31 100644 --- a/benchmark/barback/README.md +++ b/benchmark/barback/README.md @@ -53,7 +53,7 @@ BENCH_REPEATS=3 ./run-comparison-matrix.sh Resume an interrupted repeated matrix with `BENCH_REPEAT_START`, keeping `BENCH_REPEATS` set to the final repeat number. -Set `CANDIDATE_REF` to benchmark a frozen candidate while using newer harness code. All three libraries are exported to temporary immutable contexts before the matrix starts. +Set `CANDIDATE_REF` to benchmark a frozen candidate while using newer harness code. The comparison driver selects the legacy build mode when that revision lacks APIs imported by newer smoke checks. Set `CANDIDATE_BUILD_TAGS` explicitly to override this detection for a custom library. All three libraries are exported to temporary immutable contexts before the matrix starts. The archived upstream baseline receives only `patches/barback-socket-config.patch`, the same URL, Origin, and Noise certificate-authority injection already present before PR #3. The patch is required to connect upstream WhatsMeow to Barback and contains no runtime optimization. diff --git a/benchmark/barback/run-comparison-matrix.sh b/benchmark/barback/run-comparison-matrix.sh index 6757b8a03..475418610 100755 --- a/benchmark/barback/run-comparison-matrix.sh +++ b/benchmark/barback/run-comparison-matrix.sh @@ -16,6 +16,24 @@ candidate_ref=${CANDIDATE_REF:-HEAD} whatsmeow_sha=$(git -C "$repo_dir" rev-parse "$whatsmeow_ref^{commit}") pre_pr3_sha=$(git -C "$repo_dir" rev-parse "$pre_pr3_ref^{commit}") candidate_sha=$(git -C "$repo_dir" rev-parse "$candidate_ref^{commit}") +candidate_build_tags=${CANDIDATE_BUILD_TAGS-} +if [[ ! -v CANDIDATE_BUILD_TAGS ]]; then + required_candidate_symbols=( + 'func (cli *Client) GetCatalog(' + 'func (cli *Client) GetCatalogProduct(' + 'func (cli *Client) GetCatalogProducts(' + 'func (cli *Client) GetProductCollection(' + 'func (cli *Client) GetProductCollections(' + 'func (cli *Client) GetOrderDetails(' + 'func (cli *Client) GetIdentityVerificationCodes(' + ) + for symbol in "${required_candidate_symbols[@]}"; do + if ! git -C "$repo_dir" grep -Fq "$symbol" "$candidate_sha" -- '*.go'; then + candidate_build_tags=benchmark_legacy + break + fi + done +fi repeats=${BENCH_REPEATS:-1} repeat_start=${BENCH_REPEAT_START:-1} if ! [[ $repeats =~ ^[1-9][0-9]*$ ]]; then @@ -45,6 +63,8 @@ run_revision() { local build_tags= if [[ $name != hypermeow ]]; then build_tags=benchmark_legacy + else + build_tags=$candidate_build_tags fi if ((repeats > 1)); then variant="${name}-r${repeat}" From b8fcd800f0771adf7cfeb56dfc2b7a79117c91a5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 04:24:44 +0300 Subject: [PATCH 010/163] fix: align benchmark API detection --- benchmark/barback/run-comparison-matrix.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/benchmark/barback/run-comparison-matrix.sh b/benchmark/barback/run-comparison-matrix.sh index 475418610..96b0d24fb 100755 --- a/benchmark/barback/run-comparison-matrix.sh +++ b/benchmark/barback/run-comparison-matrix.sh @@ -25,8 +25,10 @@ if [[ ! -v CANDIDATE_BUILD_TAGS ]]; then 'func (cli *Client) GetProductCollection(' 'func (cli *Client) GetProductCollections(' 'func (cli *Client) GetOrderDetails(' - 'func (cli *Client) GetIdentityVerificationCodes(' ) + if [[ -f "$benchmark_dir/cmd/bench/security_code_smoke.go" ]]; then + required_candidate_symbols+=('func (cli *Client) GetIdentityVerificationCodes(') + fi for symbol in "${required_candidate_symbols[@]}"; do if ! git -C "$repo_dir" grep -Fq "$symbol" "$candidate_sha" -- '*.go'; then candidate_build_tags=benchmark_legacy From 19ea6711f858494484bf75a0e3604fa680156405 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 04:45:08 +0300 Subject: [PATCH 011/163] fix: refresh upstream benchmark socket patch --- .gitattributes | 1 + .pre-commit-config.yaml | 2 +- .../patches/barback-socket-config.patch | 24 +++++++++---------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.gitattributes b/.gitattributes index f8256663f..39b0e4990 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ *.pb.go linguist-generated=true *.pb.raw binary linguist-generated=true internals.go linguist-generated=true +*.patch -whitespace diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59fb92424..168c941d2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: hooks: - id: trailing-whitespace exclude_types: [markdown] - exclude: LICENSE + exclude: '(^LICENSE$|\.patch$)' - id: end-of-file-fixer exclude: LICENSE - id: check-yaml diff --git a/benchmark/barback/patches/barback-socket-config.patch b/benchmark/barback/patches/barback-socket-config.patch index 6a2dd525a..3c3728bf7 100644 --- a/benchmark/barback/patches/barback-socket-config.patch +++ b/benchmark/barback/patches/barback-socket-config.patch @@ -1,19 +1,19 @@ diff --git a/client.go b/client.go -index 313970d..17a33c3 100644 +index 9e333f8..d2b995b 100644 --- a/client.go +++ b/client.go -@@ -206,6 +206,7 @@ type Client struct { +@@ -207,6 +207,7 @@ type Client struct { // separate library for all the non-e2ee-related stuff like logging in. // The library is currently embedded in mautrix-meta (https://github.com/mautrix/meta), but may be separated later. MessengerConfig *MessengerConfig + SocketConfig *SocketConfig RefreshCAT func(context.Context) error - } - -@@ -221,6 +222,13 @@ type MessengerConfig struct { + // The user agent to use (for non-Messenger connections). + UserAgent string +@@ -225,6 +226,13 @@ type MessengerConfig struct { WebsocketURL string } - + +// SocketConfig overrides the WebSocket endpoint or Noise certificate authority. +type SocketConfig struct { + URL string @@ -24,9 +24,9 @@ index 313970d..17a33c3 100644 // Size of buffer for the channel that all incoming XML nodes go through. // In general it shouldn't go past a few buffered messages, but the channel is big to be safe. const handlerQueueSize = 2048 -@@ -557,6 +565,14 @@ func (cli *Client) unlockedConnect(ctx context.Context) error { - //fs.HTTPHeaders.Set("Sec-Fetch-Mode", "websocket") - //fs.HTTPHeaders.Set("Sec-Fetch-Site", "cross-site") +@@ -568,6 +576,14 @@ func (cli *Client) unlockedConnect(ctx context.Context) error { + fs.URL = cli.MessengerConfig.WebsocketURL + fs.HTTPHeaders.Set("Origin", cli.MessengerConfig.BaseURL) } + if cli.SocketConfig != nil { + if cli.SocketConfig.URL != "" { @@ -36,9 +36,9 @@ index 313970d..17a33c3 100644 + fs.HTTPHeaders.Set("Origin", cli.SocketConfig.Origin) + } + } + maps.Copy(fs.HTTPHeaders, cli.WebSocketHeaders) if err := fs.Connect(ctx); err != nil { fs.Close(0) - return err diff --git a/handshake.go b/handshake.go index 8badf1a..8f9d86c 100644 --- a/handshake.go @@ -56,11 +56,11 @@ index 8badf1a..8f9d86c 100644 + if err = verifyServerCert(certDecrypted, staticDecrypted, certAuthority); err != nil { return fmt.Errorf("failed to verify server cert: %w", err) } - + @@ -140,7 +145,7 @@ func checkCertValidity(cert *waCert.CertChain_NoiseCertificate_Details) error { return nil } - + -func verifyServerCert(certDecrypted, staticDecrypted []byte) error { +func verifyServerCert(certDecrypted, staticDecrypted []byte, certAuthority [32]byte) error { var certChain waCert.CertChain From ef4d45b8b6279c8b07eccc946b0eacb09416d3fd Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:05:42 +0300 Subject: [PATCH 012/163] fix(bench): skip business smoke on legacy baselines --- .../barback/cmd/bench/business_app_smoke_legacy.go | 3 +-- .../cmd/bench/business_app_smoke_legacy_test.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go diff --git a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go index a2db84043..ce1e5a308 100644 --- a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go +++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go @@ -4,11 +4,10 @@ package main import ( "context" - "fmt" "go.mau.fi/whatsmeow" ) func validateBusinessApp(context.Context, *whatsmeow.Client) error { - return fmt.Errorf("business app validation requires HyperMeow") + return nil } diff --git a/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go b/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go new file mode 100644 index 000000000..1c1206306 --- /dev/null +++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go @@ -0,0 +1,14 @@ +//go:build benchmark_legacy + +package main + +import ( + "context" + "testing" +) + +func TestLegacyBusinessAppValidationIsSkipped(t *testing.T) { + if err := validateBusinessApp(context.Background(), nil); err != nil { + t.Fatalf("legacy baseline rejected business smoke validation: %v", err) + } +} From 3f3a2bd0680dbcc716a638187e319c0e7ed3937c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 08:20:46 +0300 Subject: [PATCH 013/163] fix(bench): report skipped business smoke --- benchmark/barback/cmd/bench/business_app_smoke.go | 4 ++++ benchmark/barback/cmd/bench/business_app_smoke_legacy.go | 4 ++++ benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go | 3 +++ benchmark/barback/cmd/bench/main.go | 2 +- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/benchmark/barback/cmd/bench/business_app_smoke.go b/benchmark/barback/cmd/bench/business_app_smoke.go index c1b78b088..889632905 100644 --- a/benchmark/barback/cmd/bench/business_app_smoke.go +++ b/benchmark/barback/cmd/bench/business_app_smoke.go @@ -10,6 +10,10 @@ import ( "go.mau.fi/whatsmeow/types" ) +func businessAppSmokeSupported() bool { + return true +} + func validateBusinessApp(ctx context.Context, client *whatsmeow.Client) error { business := types.NewJID("15551234567", types.DefaultUserServer) catalog, err := client.GetCatalog(ctx, business, whatsmeow.GetCatalogParams{Limit: 50}) diff --git a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go index ce1e5a308..8ad4360a4 100644 --- a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go +++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go @@ -8,6 +8,10 @@ import ( "go.mau.fi/whatsmeow" ) +func businessAppSmokeSupported() bool { + return false +} + func validateBusinessApp(context.Context, *whatsmeow.Client) error { return nil } diff --git a/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go b/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go index 1c1206306..9000831c8 100644 --- a/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go +++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go @@ -8,6 +8,9 @@ import ( ) func TestLegacyBusinessAppValidationIsSkipped(t *testing.T) { + if businessAppSmokeSupported() { + t.Fatal("legacy baseline reported business smoke support") + } if err := validateBusinessApp(context.Background(), nil); err != nil { t.Fatalf("legacy baseline rejected business smoke validation: %v", err) } diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index dd24eeecb..dc641c87b 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -387,7 +387,7 @@ func (r *runner) run() (result, error) { return r.snapshot(false), fmt.Errorf("connect: %w", err) } defer client.Disconnect() - if r.cfg.BusinessSmoke { + if r.cfg.BusinessSmoke && businessAppSmokeSupported() { connectionTimer := time.NewTimer(30 * time.Second) defer connectionTimer.Stop() select { From a2af2fa4aee87e0e3c2d88386da192d1e7863e21 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 05:23:15 +0300 Subject: [PATCH 014/163] feat: add strict newsletter deletion --- mex/bindings.go | 2 ++ mex/bindings_test.go | 1 + mex/spec.json | 5 +++ newsletter_delete.go | 68 +++++++++++++++++++++++++++++++++++++++ newsletter_delete_test.go | 61 +++++++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+) create mode 100644 newsletter_delete.go create mode 100644 newsletter_delete_test.go diff --git a/mex/bindings.go b/mex/bindings.go index 442b91e80..a89bdef0e 100644 --- a/mex/bindings.go +++ b/mex/bindings.go @@ -18,6 +18,7 @@ const WebVersion = "2.3000.1044776264" const ( BizCreateOrder OperationName = "BizCreateOrder" BizQueryOrder OperationName = "BizQueryOrder" + DeleteNewsletter OperationName = "DeleteNewsletter" QueryCatalog OperationName = "QueryCatalog" QueryCatalogProduct OperationName = "QueryCatalogProduct" QueryProductCollections OperationName = "QueryProductCollections" @@ -35,6 +36,7 @@ type Operation struct { var operations = map[OperationName]Operation{ BizCreateOrder: {Name: BizCreateOrder, DocumentID: "26486627094287046", Kind: KindMutation, ResponseDiscriminator: "xwa_checkout_place_order"}, BizQueryOrder: {Name: BizQueryOrder, DocumentID: "26593811266898374", Kind: KindQuery, ResponseDiscriminator: "xwa_checkout_get_order_info"}, + DeleteNewsletter: {Name: DeleteNewsletter, DocumentID: "30062808666639665", Kind: KindMutation, ResponseDiscriminator: "xwa2_newsletter_delete_v2"}, QueryCatalog: {Name: QueryCatalog, DocumentID: "30445081048424116", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_product_catalog"}, QueryCatalogProduct: {Name: QueryCatalogProduct, DocumentID: "9660926520672123", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_product"}, QueryProductCollections: {Name: QueryProductCollections, DocumentID: "9430970660362540", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_collections"}, diff --git a/mex/bindings_test.go b/mex/bindings_test.go index 5fbcc5001..8db7adf71 100644 --- a/mex/bindings_test.go +++ b/mex/bindings_test.go @@ -7,6 +7,7 @@ func TestCatalogBindingsMatchPinnedSpec(t *testing.T) { t.Fatalf("source revision = %q", SourceRevision) } tests := map[OperationName]string{ + DeleteNewsletter: "30062808666639665", QueryCatalog: "30445081048424116", QueryCatalogProduct: "9660926520672123", QueryProductCollections: "9430970660362540", diff --git a/mex/spec.json b/mex/spec.json index 550b98d25..014f7d617 100644 --- a/mex/spec.json +++ b/mex/spec.json @@ -2,6 +2,11 @@ "sourceRevision": "74509efe262b37b26bed05486c9f4160db5e841b", "waVersion": "2.3000.1044776264", "operations": { + "DeleteNewsletter": { + "documentId": "30062808666639665", + "kind": "mutation", + "responseDiscriminator": "xwa2_newsletter_delete_v2" + }, "BizCreateOrder": { "documentId": "26486627094287046", "kind": "mutation", diff --git a/newsletter_delete.go b/newsletter_delete.go new file mode 100644 index 000000000..991f03fd1 --- /dev/null +++ b/newsletter_delete.go @@ -0,0 +1,68 @@ +package whatsmeow + +import ( + "context" + "encoding/json" + "fmt" + + "go.mau.fi/whatsmeow/mex" + "go.mau.fi/whatsmeow/types" +) + +type deleteNewsletterVariables struct { + NewsletterID string `json:"newsletter_id"` +} + +func buildDeleteNewsletterVariables(jid types.JID) (deleteNewsletterVariables, error) { + if jid.IsEmpty() || jid.User == "" || jid.Server != types.NewsletterServer { + return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID must use the newsletter server") + } + if jid.RawAgent != 0 || jid.Device != 0 || jid.Integrator != 0 { + return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID must not identify a device") + } + return deleteNewsletterVariables{NewsletterID: jid.String()}, nil +} + +func decodeDeleteNewsletterResponse(data json.RawMessage, requested types.JID) error { + var response struct { + Deleted *struct { + ID string `json:"id"` + State *struct { + Type string `json:"type"` + } `json:"state"` + } `json:"xwa2_newsletter_delete_v2"` + } + if err := json.Unmarshal(data, &response); err != nil { + return fmt.Errorf("decode newsletter deletion response: %w", err) + } + if response.Deleted == nil { + return fmt.Errorf("newsletter deletion response is missing xwa2_newsletter_delete_v2") + } + if response.Deleted.ID != requested.String() { + return fmt.Errorf("newsletter deletion response ID %q does not match %q", response.Deleted.ID, requested) + } + if response.Deleted.State == nil || response.Deleted.State.Type != "DELETED" { + return fmt.Errorf("newsletter deletion response did not confirm DELETED state") + } + return nil +} + +// DeleteNewsletter permanently deletes a WhatsApp channel owned by the client. +func (cli *Client) DeleteNewsletter(ctx context.Context, jid types.JID) error { + if cli == nil { + return ErrClientIsNil + } + variables, err := buildDeleteNewsletterVariables(jid) + if err != nil { + return err + } + binding, ok := mex.Lookup(mex.DeleteNewsletter) + if !ok { + return fmt.Errorf("missing DeleteNewsletter MEX binding") + } + data, err := cli.sendMexIQ(ctx, binding.DocumentID, variables) + if err != nil { + return fmt.Errorf("delete newsletter: %w", err) + } + return decodeDeleteNewsletterResponse(data, jid) +} diff --git a/newsletter_delete_test.go b/newsletter_delete_test.go new file mode 100644 index 000000000..9861c533a --- /dev/null +++ b/newsletter_delete_test.go @@ -0,0 +1,61 @@ +package whatsmeow + +import ( + "encoding/json" + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestBuildDeleteNewsletterVariablesRejectsNonNewsletterJID(t *testing.T) { + tests := []types.JID{ + types.EmptyJID, + types.NewJID("15551234567", types.DefaultUserServer), + types.NewJID("120363000000000000", types.GroupServer), + } + for _, jid := range tests { + if _, err := buildDeleteNewsletterVariables(jid); err == nil { + t.Errorf("expected %q to be rejected", jid) + } + } +} + +func TestBuildDeleteNewsletterVariablesUsesCanonicalJID(t *testing.T) { + jid := types.NewJID("120363000000000001", types.NewsletterServer) + got, err := buildDeleteNewsletterVariables(jid) + if err != nil { + t.Fatal(err) + } + if got.NewsletterID != "120363000000000001@newsletter" { + t.Fatalf("newsletter_id = %q", got.NewsletterID) + } +} + +func TestDecodeDeleteNewsletterResponseRequiresMatchingDeletedState(t *testing.T) { + want := types.NewJID("120363000000000001", types.NewsletterServer) + tests := []struct { + name string + raw string + }{ + {"missing discriminator", `{"unexpected":{}}`}, + {"null result", `{"xwa2_newsletter_delete_v2":null}`}, + {"wrong id", `{"xwa2_newsletter_delete_v2":{"id":"120363000000000002@newsletter","state":{"type":"DELETED"}}}`}, + {"active state", `{"xwa2_newsletter_delete_v2":{"id":"120363000000000001@newsletter","state":{"type":"ACTIVE"}}}`}, + {"missing state", `{"xwa2_newsletter_delete_v2":{"id":"120363000000000001@newsletter"}}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := decodeDeleteNewsletterResponse(json.RawMessage(tc.raw), want); err == nil { + t.Fatal("expected response validation error") + } + }) + } +} + +func TestDecodeDeleteNewsletterResponseAcceptsMatchingDeletedState(t *testing.T) { + want := types.NewJID("120363000000000001", types.NewsletterServer) + raw := json.RawMessage(`{"xwa2_newsletter_delete_v2":{"id":"120363000000000001@newsletter","state":{"type":"DELETED"}}}`) + if err := decodeDeleteNewsletterResponse(raw, want); err != nil { + t.Fatal(err) + } +} From 93c03d7478bb8a2cc062426a973de636dfa1aa38 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 05:32:19 +0300 Subject: [PATCH 015/163] fix: bound newsletter deletion JIDs --- newsletter_delete.go | 8 ++++++++ newsletter_delete_test.go | 3 +++ 2 files changed, 11 insertions(+) diff --git a/newsletter_delete.go b/newsletter_delete.go index 991f03fd1..b16c14b3b 100644 --- a/newsletter_delete.go +++ b/newsletter_delete.go @@ -17,6 +17,14 @@ func buildDeleteNewsletterVariables(jid types.JID) (deleteNewsletterVariables, e if jid.IsEmpty() || jid.User == "" || jid.Server != types.NewsletterServer { return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID must use the newsletter server") } + if len(jid.User) > 256 { + return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID user exceeds 256 bytes") + } + for _, character := range jid.User { + if character < '0' || character > '9' { + return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID user must be numeric") + } + } if jid.RawAgent != 0 || jid.Device != 0 || jid.Integrator != 0 { return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID must not identify a device") } diff --git a/newsletter_delete_test.go b/newsletter_delete_test.go index 9861c533a..9a9c7ec0a 100644 --- a/newsletter_delete_test.go +++ b/newsletter_delete_test.go @@ -2,6 +2,7 @@ package whatsmeow import ( "encoding/json" + "strings" "testing" "go.mau.fi/whatsmeow/types" @@ -12,6 +13,8 @@ func TestBuildDeleteNewsletterVariablesRejectsNonNewsletterJID(t *testing.T) { types.EmptyJID, types.NewJID("15551234567", types.DefaultUserServer), types.NewJID("120363000000000000", types.GroupServer), + types.NewJID("not-numeric", types.NewsletterServer), + types.NewJID(strings.Repeat("1", 257), types.NewsletterServer), } for _, jid := range tests { if _, err := buildDeleteNewsletterVariables(jid); err == nil { From 3ece35a87c8f4843cab6813ec29217ef22ef25d2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 00:41:13 +0300 Subject: [PATCH 016/163] build: format MEX binding test --- mex/bindings_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mex/bindings_test.go b/mex/bindings_test.go index 8db7adf71..04ef94f48 100644 --- a/mex/bindings_test.go +++ b/mex/bindings_test.go @@ -7,7 +7,7 @@ func TestCatalogBindingsMatchPinnedSpec(t *testing.T) { t.Fatalf("source revision = %q", SourceRevision) } tests := map[OperationName]string{ - DeleteNewsletter: "30062808666639665", + DeleteNewsletter: "30062808666639665", QueryCatalog: "30445081048424116", QueryCatalogProduct: "9660926520672123", QueryProductCollections: "9430970660362540", From b37ee4f3a0cb4b56c591eeb042c40c8ecdbff82a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 00:57:04 +0300 Subject: [PATCH 017/163] fix: reject unsupported desktop channel deletion --- newsletter.go | 6 ++++++ newsletter_delete_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/newsletter.go b/newsletter.go index f9022bf52..17122b4c0 100644 --- a/newsletter.go +++ b/newsletter.go @@ -118,6 +118,7 @@ const ( mutationCreateNewsletter = "6234210096708695" mutationUnfollowNewsletter = "6392786840836363" mutationFollowNewsletter = "9926858900719341" + mutationDeleteNewsletter = "30062808666639665" // desktop & mobile queryFetchNewsletterDesktop = "9779843322044422" @@ -155,6 +156,8 @@ func convertQueryID(cli *Client, queryID string) string { return mutationUnfollowNewsletterDesktop case mutationFollowNewsletter: return mutationFollowNewsletterDesktop + case mutationDeleteNewsletter: + return "" default: return queryID } @@ -168,6 +171,9 @@ func (cli *Client) sendMexIQ(ctx context.Context, queryID string, variables any) return nil, fmt.Errorf("argo decoding is currently broken") } queryID = convertQueryID(cli, queryID) + if queryID == "" { + return nil, fmt.Errorf("MEX query is unsupported for this client platform") + } payload, err := json.Marshal(map[string]any{ "variables": variables, }) diff --git a/newsletter_delete_test.go b/newsletter_delete_test.go index 9a9c7ec0a..e56795fed 100644 --- a/newsletter_delete_test.go +++ b/newsletter_delete_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + "go.mau.fi/whatsmeow/proto/waWa6" + "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/types" ) @@ -62,3 +64,19 @@ func TestDecodeDeleteNewsletterResponseAcceptsMatchingDeletedState(t *testing.T) t.Fatal(err) } } + +func TestDeleteNewsletterQueryIsRejectedForDesktopPayloads(t *testing.T) { + originalPayload := store.BaseClientPayload + store.BaseClientPayload = &waWa6.ClientPayload{ + UserAgent: &waWa6.ClientPayload_UserAgent{}, + } + t.Cleanup(func() { + store.BaseClientPayload = originalPayload + }) + + jid := types.NewJID("15551234567", types.DefaultUserServer) + client := &Client{Store: &store.Device{ID: &jid}} + if got := convertQueryID(client, "30062808666639665"); got != "" { + t.Fatalf("desktop query ID = %q, want unsupported", got) + } +} From f5bd3fb0c1a1b8f33057b3fe3e0aff7378685379 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 07:26:54 +0300 Subject: [PATCH 018/163] feat: batch chat label mutations --- appstate/encode.go | 13 +++++++++++++ appstate/encode_label_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 appstate/encode_label_test.go diff --git a/appstate/encode.go b/appstate/encode.go index c101a55c5..dfbc19cb5 100644 --- a/appstate/encode.go +++ b/appstate/encode.go @@ -162,6 +162,19 @@ func BuildLabelChat(target types.JID, labelID string, labeled bool) PatchInfo { } } +type LabelChatChange struct { + LabelID string + Labeled bool +} + +func BuildLabelChatChanges(target types.JID, changes []LabelChatChange) PatchInfo { + mutations := make([]MutationInfo, len(changes)) + for index, change := range changes { + mutations[index] = newLabelChatMutation(target, change.LabelID, change.Labeled) + } + return PatchInfo{Type: WAPatchRegular, Mutations: mutations} +} + func newLabelMessageMutation(target types.JID, labelID, messageID string, labeled bool) MutationInfo { return MutationInfo{ Index: []string{IndexLabelAssociationMessage, labelID, target.String(), messageID, "0", "0"}, diff --git a/appstate/encode_label_test.go b/appstate/encode_label_test.go new file mode 100644 index 000000000..0e59d362a --- /dev/null +++ b/appstate/encode_label_test.go @@ -0,0 +1,30 @@ +package appstate + +import ( + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestBuildLabelChatChangesUsesOnePatch(t *testing.T) { + target := types.NewJID("15551234567", types.DefaultUserServer) + patch := BuildLabelChatChanges(target, []LabelChatChange{ + {LabelID: "1", Labeled: false}, + {LabelID: "2", Labeled: true}, + }) + if patch.Type != WAPatchRegular || len(patch.Mutations) != 2 { + t.Fatalf("patch = %#v", patch) + } + for index, want := range []struct { + labelID string + labeled bool + }{{"1", false}, {"2", true}} { + mutation := patch.Mutations[index] + if mutation.Version != 3 || len(mutation.Index) != 3 || mutation.Index[1] != want.labelID || mutation.Index[2] != target.String() { + t.Fatalf("mutation %d = %#v", index, mutation) + } + if mutation.Value.GetLabelAssociationAction().GetLabeled() != want.labeled { + t.Fatalf("mutation %d labeled = %v", index, mutation.Value.GetLabelAssociationAction().GetLabeled()) + } + } +} From 17b99ca4c45f5aedc97d2300da16225681accf06 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 07:34:40 +0300 Subject: [PATCH 019/163] feat: selectively emit label full-sync events --- appstate.go | 24 ++++++++++++++++++++---- appstate_label_events_test.go | 29 +++++++++++++++++++++++++++++ client.go | 1 + 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 appstate_label_events_test.go diff --git a/appstate.go b/appstate.go index e46a651ad..37abd01c8 100644 --- a/appstate.go +++ b/appstate.go @@ -67,7 +67,7 @@ func (cli *Client) fetchAppState(ctx context.Context, name appstate.WAPatchName, wantSnapshot := fullSync var eventsToDispatch []any eventsToDispatchPtr := &eventsToDispatch - if fullSync && !cli.EmitAppStateEventsOnFullSync { + if fullSync && !cli.EmitAppStateEventsOnFullSync && !cli.EmitLabelEventsOnFullSync { eventsToDispatchPtr = nil } for hasMore { @@ -108,7 +108,7 @@ func (cli *Client) handleAppStateRecovery( } var eventsToDispatch []any eventsToDispatchPtr := &eventsToDispatch - if !cli.EmitAppStateEventsOnFullSync { + if !cli.EmitAppStateEventsOnFullSync && !cli.EmitLabelEventsOnFullSync { eventsToDispatchPtr = nil } snapshot, err := appstate.ParseRecovery(result[0].GetSyncdSnapshotFatalRecoveryResponse()) @@ -186,17 +186,33 @@ func (cli *Client) collectEventsToDispatch( } } for _, mutation := range mutations { - if eventsToDispatch != nil && mutation.Operation == waServerSync.SyncdMutation_SET { + emitMutation := eventsToDispatch != nil && (!fullSync || cli.shouldEmitFullSyncMutation(mutation.Index)) + if emitMutation && mutation.Operation == waServerSync.SyncdMutation_SET { *eventsToDispatch = append(*eventsToDispatch, &events.AppState{Index: mutation.Index, SyncActionValue: mutation.Action}) } evt := cli.dispatchAppState(ctx, name, mutation, fullSync) - if eventsToDispatch != nil && evt != nil { + if emitMutation && evt != nil { *eventsToDispatch = append(*eventsToDispatch, evt) } } return nil } +func (cli *Client) shouldEmitFullSyncMutation(index []string) bool { + if cli.EmitAppStateEventsOnFullSync { + return true + } + if !cli.EmitLabelEventsOnFullSync || len(index) == 0 { + return false + } + switch index[0] { + case appstate.IndexLabelEdit, appstate.IndexLabelAssociationChat, appstate.IndexLabelAssociationMessage: + return true + default: + return false + } +} + func (cli *Client) filterContacts(mutations []appstate.Mutation) ([]appstate.Mutation, []store.ContactEntry) { filteredMutations := mutations[:0] contacts := make([]store.ContactEntry, 0, len(mutations)) diff --git a/appstate_label_events_test.go b/appstate_label_events_test.go new file mode 100644 index 000000000..b8d98c6a6 --- /dev/null +++ b/appstate_label_events_test.go @@ -0,0 +1,29 @@ +package whatsmeow + +import ( + "testing" + + "go.mau.fi/whatsmeow/appstate" +) + +func TestSelectiveFullSyncLabelEvents(t *testing.T) { + client := &Client{EmitLabelEventsOnFullSync: true} + for _, test := range []struct { + index string + want bool + }{ + {appstate.IndexLabelEdit, true}, + {appstate.IndexLabelAssociationChat, true}, + {appstate.IndexLabelAssociationMessage, true}, + {appstate.IndexMute, false}, + {"", false}, + } { + if got := client.shouldEmitFullSyncMutation([]string{test.index}); got != test.want { + t.Fatalf("index %q: got %v, want %v", test.index, got, test.want) + } + } + client.EmitAppStateEventsOnFullSync = true + if !client.shouldEmitFullSyncMutation([]string{appstate.IndexMute}) { + t.Fatal("full event mode did not emit non-label mutation") + } +} diff --git a/client.go b/client.go index f8f13f737..2eb02bc47 100644 --- a/client.go +++ b/client.go @@ -125,6 +125,7 @@ type Client struct { // EmitAppStateEventsOnFullSync can be set to true if you want to get app state events emitted // even when re-syncing the whole state. EmitAppStateEventsOnFullSync bool + EmitLabelEventsOnFullSync bool AppStateDebugLogs bool AutomaticMessageRerequestFromPhone bool From 5511bb5c4a7f55e38e8ce43cc8e8decde00d4ef0 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:51:46 +0300 Subject: [PATCH 020/163] fix: deduplicate atomic label changes --- appstate/encode.go | 13 ++++++++++--- appstate/encode_label_test.go | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/appstate/encode.go b/appstate/encode.go index dfbc19cb5..8a3226ce3 100644 --- a/appstate/encode.go +++ b/appstate/encode.go @@ -168,9 +168,16 @@ type LabelChatChange struct { } func BuildLabelChatChanges(target types.JID, changes []LabelChatChange) PatchInfo { - mutations := make([]MutationInfo, len(changes)) - for index, change := range changes { - mutations[index] = newLabelChatMutation(target, change.LabelID, change.Labeled) + mutations := make([]MutationInfo, 0, len(changes)) + mutationIndexes := make(map[string]int, len(changes)) + for _, change := range changes { + mutation := newLabelChatMutation(target, change.LabelID, change.Labeled) + if index, exists := mutationIndexes[change.LabelID]; exists { + mutations[index] = mutation + } else { + mutationIndexes[change.LabelID] = len(mutations) + mutations = append(mutations, mutation) + } } return PatchInfo{Type: WAPatchRegular, Mutations: mutations} } diff --git a/appstate/encode_label_test.go b/appstate/encode_label_test.go index 0e59d362a..330bc7f21 100644 --- a/appstate/encode_label_test.go +++ b/appstate/encode_label_test.go @@ -28,3 +28,18 @@ func TestBuildLabelChatChangesUsesOnePatch(t *testing.T) { } } } + +func TestBuildLabelChatChangesDeduplicatesLabelIDs(t *testing.T) { + target := types.NewJID("15551234567", types.DefaultUserServer) + patch := BuildLabelChatChanges(target, []LabelChatChange{ + {LabelID: "1", Labeled: false}, + {LabelID: "2", Labeled: true}, + {LabelID: "1", Labeled: true}, + }) + if len(patch.Mutations) != 2 { + t.Fatalf("mutation count = %d, want 2", len(patch.Mutations)) + } + if patch.Mutations[0].Index[1] != "1" || !patch.Mutations[0].Value.GetLabelAssociationAction().GetLabeled() { + t.Fatalf("duplicate label did not preserve its final state: %#v", patch.Mutations[0]) + } +} From b91c2acaeac6ff9fee9545f9332a4fd8ea51809d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 08:03:22 +0300 Subject: [PATCH 021/163] feat: control history sync acknowledgements --- client.go | 5 +++-- historysync_test.go | 30 ++++++++++++++++++++++++++++++ message.go | 21 +++++++++++++++------ types/events/events.go | 1 + 4 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 historysync_test.go diff --git a/client.go b/client.go index 2eb02bc47..789094e09 100644 --- a/client.go +++ b/client.go @@ -135,10 +135,11 @@ type Client struct { appStateProc *appstate.Processor appStateSyncLock sync.Mutex - historySyncNotifications chan *waE2E.HistorySyncNotification + historySyncNotifications chan historySyncNotification historySyncHandlerStarted atomic.Bool ManualHistorySyncDownload bool DisableManualHistorySyncReceipt bool + DisableHistorySyncReceipt bool uploadPreKeysLock sync.Mutex lastPreKeyUpload time.Time @@ -324,7 +325,7 @@ func NewClient(deviceStore *store.Device, log waLog.Logger) *Client { socketWait: make(chan struct{}), expectedDisconnect: exsync.NewEvent(), - historySyncNotifications: make(chan *waE2E.HistorySyncNotification, 32), + historySyncNotifications: make(chan historySyncNotification, 32), GetMessageForRetry: func(requester, to types.JID, id types.MessageID) *waE2E.Message { return nil }, diff --git a/historysync_test.go b/historysync_test.go new file mode 100644 index 000000000..284d1b567 --- /dev/null +++ b/historysync_test.go @@ -0,0 +1,30 @@ +package whatsmeow + +import "testing" + +func TestHistorySyncReceiptPolicy(t *testing.T) { + for _, test := range []struct { + name string + manual bool + disableManual bool + disableAll bool + want bool + }{ + {name: "automatic", want: true}, + {name: "manual default", manual: true, want: true}, + {name: "manual disabled", manual: true, disableManual: true}, + {name: "automatic disabled", disableAll: true}, + {name: "manual globally disabled", manual: true, disableAll: true}, + } { + t.Run(test.name, func(t *testing.T) { + client := &Client{ + ManualHistorySyncDownload: test.manual, + DisableManualHistorySyncReceipt: test.disableManual, + DisableHistorySyncReceipt: test.disableAll, + } + if got := client.shouldSendHistorySyncReceipt(); got != test.want { + t.Fatalf("should send receipt = %t", got) + } + }) + } +} diff --git a/message.go b/message.go index 328056d82..5bbd96d02 100644 --- a/message.go +++ b/message.go @@ -651,6 +651,11 @@ func (cli *Client) decryptGroupMsg(ctx context.Context, child *waBinary.Node, fr const checkPadding = true +type historySyncNotification struct { + messageID types.MessageID + notification *waE2E.HistorySyncNotification +} + func isValidPadding(plaintext []byte) bool { lastByte := plaintext[len(plaintext)-1] expectedPadding := bytes.Repeat([]byte{lastByte}, int(lastByte)) @@ -713,13 +718,13 @@ func (cli *Client) handleHistorySyncNotificationLoop() { ctx := cli.BackgroundEventCtx for { select { - case notif := <-cli.historySyncNotifications: - blob, err := cli.DownloadHistorySync(ctx, notif, false) + case queued := <-cli.historySyncNotifications: + blob, err := cli.DownloadHistorySync(ctx, queued.notification, false) if err != nil { cli.Log.Errorf("Failed to download history sync: %v", err) } else { - cli.dispatchEvent(&events.HistorySync{Data: blob, Notification: notif}) - err = cli.DeleteMedia(ctx, MediaHistory, notif.GetDirectPath(), notif.GetFileEncSHA256(), notif.GetEncHandle()) + cli.dispatchEvent(&events.HistorySync{Data: blob, Notification: queued.notification, MessageID: queued.messageID}) + err = cli.DeleteMedia(ctx, MediaHistory, queued.notification.GetDirectPath(), queued.notification.GetFileEncSHA256(), queued.notification.GetEncHandle()) if err != nil { cli.Log.Warnf("Failed to delete history sync media from server: %v", err) } @@ -877,12 +882,12 @@ func (cli *Client) handleProtocolMessage(ctx context.Context, info *types.Messag if protoMsg.GetHistorySyncNotification() != nil { if !cli.ManualHistorySyncDownload { - cli.historySyncNotifications <- protoMsg.HistorySyncNotification + cli.historySyncNotifications <- historySyncNotification{messageID: info.ID, notification: protoMsg.HistorySyncNotification} if cli.historySyncHandlerStarted.CompareAndSwap(false, true) { go cli.handleHistorySyncNotificationLoop() } } - if !(cli.ManualHistorySyncDownload && cli.DisableManualHistorySyncReceipt) { + if cli.shouldSendHistorySyncReceipt() { go func() { err := cli.SendProtocolMessageReceipt(ctx, info.ID, types.ReceiptTypeHistorySync) if err != nil { @@ -921,6 +926,10 @@ func (cli *Client) handleProtocolMessage(ctx context.Context, info *types.Messag return } +func (cli *Client) shouldSendHistorySyncReceipt() bool { + return !cli.DisableHistorySyncReceipt && !(cli.ManualHistorySyncDownload && cli.DisableManualHistorySyncReceipt) +} + func (cli *Client) processProtocolParts(ctx context.Context, info *types.MessageInfo, msg *waE2E.Message) (ok bool) { ok = true cli.storeMessageSecret(ctx, info, msg) diff --git a/types/events/events.go b/types/events/events.go index a9b1c48c8..28cfa1b6c 100644 --- a/types/events/events.go +++ b/types/events/events.go @@ -271,6 +271,7 @@ type HistorySync struct { Data *waHistorySync.HistorySync Notification *waE2E.HistorySyncNotification + MessageID types.MessageID } type DecryptFailMode string From 1376c085324381d2b253f2d5e55a1e3a15bab34d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 08:10:01 +0300 Subject: [PATCH 022/163] feat: defer history sync side effects --- client.go | 2 ++ historysync_test.go | 10 ++++++++++ message.go | 20 ++++++++++++++++---- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/client.go b/client.go index 789094e09..1d7b42f10 100644 --- a/client.go +++ b/client.go @@ -140,6 +140,8 @@ type Client struct { ManualHistorySyncDownload bool DisableManualHistorySyncReceipt bool DisableHistorySyncReceipt bool + DisableHistorySyncStorage bool + DisableHistorySyncMediaDelete bool uploadPreKeysLock sync.Mutex lastPreKeyUpload time.Time diff --git a/historysync_test.go b/historysync_test.go index 284d1b567..a9c5380aa 100644 --- a/historysync_test.go +++ b/historysync_test.go @@ -28,3 +28,13 @@ func TestHistorySyncReceiptPolicy(t *testing.T) { }) } } + +func TestHistorySyncSideEffectsCanBeDisabledIndependently(t *testing.T) { + client := &Client{DisableHistorySyncReceipt: true, DisableHistorySyncStorage: true, DisableHistorySyncMediaDelete: true} + if client.shouldSendHistorySyncReceipt() { + t.Fatal("receipt was enabled") + } + if client.shouldStoreHistorySync() || client.shouldDeleteHistorySyncMedia() { + t.Fatal("history side effect was enabled") + } +} diff --git a/message.go b/message.go index 5bbd96d02..2b9a2bf05 100644 --- a/message.go +++ b/message.go @@ -724,9 +724,11 @@ func (cli *Client) handleHistorySyncNotificationLoop() { cli.Log.Errorf("Failed to download history sync: %v", err) } else { cli.dispatchEvent(&events.HistorySync{Data: blob, Notification: queued.notification, MessageID: queued.messageID}) - err = cli.DeleteMedia(ctx, MediaHistory, queued.notification.GetDirectPath(), queued.notification.GetFileEncSHA256(), queued.notification.GetEncHandle()) - if err != nil { - cli.Log.Warnf("Failed to delete history sync media from server: %v", err) + if cli.shouldDeleteHistorySyncMedia() { + err = cli.DeleteMedia(ctx, MediaHistory, queued.notification.GetDirectPath(), queued.notification.GetFileEncSHA256(), queued.notification.GetEncHandle()) + if err != nil { + cli.Log.Warnf("Failed to delete history sync media from server: %v", err) + } } } case <-time.After(1 * time.Minute): @@ -807,7 +809,9 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History cli.storeCompanionMetaNonce(ctx, historySync.GetCompanionMetaNonce()) } } - if synchronousStorage { + if !cli.shouldStoreHistorySync() { + return &historySync, nil + } else if synchronousStorage { doStorage(ctx) } else { go doStorage(context.WithoutCancel(ctx)) @@ -930,6 +934,14 @@ func (cli *Client) shouldSendHistorySyncReceipt() bool { return !cli.DisableHistorySyncReceipt && !(cli.ManualHistorySyncDownload && cli.DisableManualHistorySyncReceipt) } +func (cli *Client) shouldStoreHistorySync() bool { + return !cli.DisableHistorySyncStorage +} + +func (cli *Client) shouldDeleteHistorySyncMedia() bool { + return !cli.DisableHistorySyncMediaDelete +} + func (cli *Client) processProtocolParts(ctx context.Context, info *types.MessageInfo, msg *waE2E.Message) (ok bool) { ok = true cli.storeMessageSecret(ctx, info, msg) From 29741e91b462c72749f2292c4a294529006a4e69 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:29:42 +0300 Subject: [PATCH 023/163] fix: retain history deletion nonce --- historysync_test.go | 11 +++++++++++ message.go | 10 +++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/historysync_test.go b/historysync_test.go index a9c5380aa..aa3896bd5 100644 --- a/historysync_test.go +++ b/historysync_test.go @@ -38,3 +38,14 @@ func TestHistorySyncSideEffectsCanBeDisabledIndependently(t *testing.T) { t.Fatal("history side effect was enabled") } } + +func TestHistorySyncDeletionKeepsCompanionNonce(t *testing.T) { + client := &Client{DisableHistorySyncStorage: true} + if !client.shouldStoreHistorySyncNonce() { + t.Fatal("media deletion did not retain its companion nonce") + } + client.DisableHistorySyncMediaDelete = true + if client.shouldStoreHistorySyncNonce() { + t.Fatal("nonce storage remained enabled without storage or deletion") + } +} diff --git a/message.go b/message.go index 2b9a2bf05..48e9a918a 100644 --- a/message.go +++ b/message.go @@ -790,6 +790,9 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History return nil, fmt.Errorf("failed to unmarshal: %w", err) } cli.Log.Debugf("Received history sync (type %s, chunk %d, progress %d)", historySync.GetSyncType(), historySync.GetChunkOrder(), historySync.GetProgress()) + if historySync.CompanionMetaNonce != nil && cli.shouldStoreHistorySyncNonce() { + cli.storeCompanionMetaNonce(ctx, historySync.GetCompanionMetaNonce()) + } doStorage := func(ctx context.Context) { if err := cli.storeNCTSalt(ctx, historySync.GetNctSalt()); err != nil { cli.Log.Warnf("Failed to store NCT salt from history sync: %v", err) @@ -805,9 +808,6 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History if historySync.GlobalSettings != nil { cli.storeGlobalSettings(ctx, historySync.GlobalSettings) } - if historySync.CompanionMetaNonce != nil { - cli.storeCompanionMetaNonce(ctx, historySync.GetCompanionMetaNonce()) - } } if !cli.shouldStoreHistorySync() { return &historySync, nil @@ -942,6 +942,10 @@ func (cli *Client) shouldDeleteHistorySyncMedia() bool { return !cli.DisableHistorySyncMediaDelete } +func (cli *Client) shouldStoreHistorySyncNonce() bool { + return cli.shouldStoreHistorySync() || cli.shouldDeleteHistorySyncMedia() +} + func (cli *Client) processProtocolParts(ctx context.Context, info *types.MessageInfo, msg *waE2E.Message) (ok bool) { ok = true cli.storeMessageSecret(ctx, info, msg) From 3d134e7aeb30269ab805363404869c5d01e51b3d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:18:08 +0300 Subject: [PATCH 024/163] fix: detach asynchronous nonce persistence --- historysync_test.go | 62 ++++++++++++++++++++++++++++++++++++++++++++- message.go | 10 +++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/historysync_test.go b/historysync_test.go index aa3896bd5..1bff1154b 100644 --- a/historysync_test.go +++ b/historysync_test.go @@ -1,6 +1,29 @@ package whatsmeow -import "testing" +import ( + "bytes" + "compress/zlib" + "context" + "testing" + + "google.golang.org/protobuf/proto" + + waE2E "go.mau.fi/whatsmeow/proto/waE2E" + waHistorySync "go.mau.fi/whatsmeow/proto/waHistorySync" + "go.mau.fi/whatsmeow/store" + waLog "go.mau.fi/whatsmeow/util/log" +) + +type historySyncDeviceContainer struct { + putContextErr chan error +} + +func (container *historySyncDeviceContainer) PutDevice(ctx context.Context, _ *store.Device) error { + container.putContextErr <- ctx.Err() + return nil +} + +func (*historySyncDeviceContainer) DeleteDevice(context.Context, *store.Device) error { return nil } func TestHistorySyncReceiptPolicy(t *testing.T) { for _, test := range []struct { @@ -49,3 +72,40 @@ func TestHistorySyncDeletionKeepsCompanionNonce(t *testing.T) { t.Fatal("nonce storage remained enabled without storage or deletion") } } + +func TestAsyncHistorySyncNoncePersistenceIgnoresCallerCancellation(t *testing.T) { + syncType := waHistorySync.HistorySync_INITIAL_BOOTSTRAP + historyBytes, err := proto.Marshal(&waHistorySync.HistorySync{ + SyncType: &syncType, + CompanionMetaNonce: proto.String("fresh"), + }) + if err != nil { + t.Fatal(err) + } + var compressed bytes.Buffer + writer := zlib.NewWriter(&compressed) + if _, err = writer.Write(historyBytes); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + + container := &historySyncDeviceContainer{putContextErr: make(chan error, 1)} + client := &Client{ + DisableHistorySyncStorage: true, + Log: waLog.Noop, + Store: &store.Device{Container: container}, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = client.DownloadHistorySync(ctx, &waE2E.HistorySyncNotification{ + InitialHistBootstrapInlinePayload: compressed.Bytes(), + }, false) + if err != nil { + t.Fatal(err) + } + if err = <-container.putContextErr; err != nil { + t.Fatalf("nonce persistence inherited caller cancellation: %v", err) + } +} diff --git a/message.go b/message.go index 48e9a918a..56315aa67 100644 --- a/message.go +++ b/message.go @@ -790,8 +790,12 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History return nil, fmt.Errorf("failed to unmarshal: %w", err) } cli.Log.Debugf("Received history sync (type %s, chunk %d, progress %d)", historySync.GetSyncType(), historySync.GetChunkOrder(), historySync.GetProgress()) + storageCtx := ctx + if !synchronousStorage { + storageCtx = context.WithoutCancel(ctx) + } if historySync.CompanionMetaNonce != nil && cli.shouldStoreHistorySyncNonce() { - cli.storeCompanionMetaNonce(ctx, historySync.GetCompanionMetaNonce()) + cli.storeCompanionMetaNonce(storageCtx, historySync.GetCompanionMetaNonce()) } doStorage := func(ctx context.Context) { if err := cli.storeNCTSalt(ctx, historySync.GetNctSalt()); err != nil { @@ -812,9 +816,9 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History if !cli.shouldStoreHistorySync() { return &historySync, nil } else if synchronousStorage { - doStorage(ctx) + doStorage(storageCtx) } else { - go doStorage(context.WithoutCancel(ctx)) + go doStorage(storageCtx) } return &historySync, nil } From e53e0910d7f56b59e81aee6c03f489622ca58e2c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 08:55:35 +0300 Subject: [PATCH 025/163] fix: keep async history sync nonblocking --- historysync_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++++ message.go | 38 +++++++++++++++++---------- 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/historysync_test.go b/historysync_test.go index 1bff1154b..b7578eb49 100644 --- a/historysync_test.go +++ b/historysync_test.go @@ -5,6 +5,7 @@ import ( "compress/zlib" "context" "testing" + "time" "google.golang.org/protobuf/proto" @@ -16,10 +17,14 @@ import ( type historySyncDeviceContainer struct { putContextErr chan error + putRelease chan struct{} } func (container *historySyncDeviceContainer) PutDevice(ctx context.Context, _ *store.Device) error { container.putContextErr <- ctx.Err() + if container.putRelease != nil { + <-container.putRelease + } return nil } @@ -109,3 +114,60 @@ func TestAsyncHistorySyncNoncePersistenceIgnoresCallerCancellation(t *testing.T) t.Fatalf("nonce persistence inherited caller cancellation: %v", err) } } + +func TestAsyncHistorySyncNoncePersistenceDoesNotBlockDownload(t *testing.T) { + syncType := waHistorySync.HistorySync_INITIAL_BOOTSTRAP + historyBytes, err := proto.Marshal(&waHistorySync.HistorySync{ + SyncType: &syncType, + CompanionMetaNonce: proto.String("fresh"), + }) + if err != nil { + t.Fatal(err) + } + var compressed bytes.Buffer + writer := zlib.NewWriter(&compressed) + if _, err = writer.Write(historyBytes); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + + container := &historySyncDeviceContainer{ + putContextErr: make(chan error, 1), + putRelease: make(chan struct{}), + } + t.Cleanup(func() { close(container.putRelease) }) + client := &Client{ + DisableHistorySyncStorage: true, + Log: waLog.Noop, + Store: &store.Device{Container: container}, + } + done := make(chan error, 1) + go func() { + _, err := client.DownloadHistorySync(context.Background(), &waE2E.HistorySyncNotification{ + InitialHistBootstrapInlinePayload: compressed.Bytes(), + }, false) + done <- err + }() + + select { + case err = <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("asynchronous nonce persistence blocked history sync download") + } + if client.Store.CompanionMetaNonce != "fresh" { + t.Fatalf("companion meta nonce = %q", client.Store.CompanionMetaNonce) + } + select { + case err = <-container.putContextErr: + if err != nil { + t.Fatalf("nonce persistence inherited caller cancellation: %v", err) + } + case <-time.After(time.Second): + t.Fatal("asynchronous nonce persistence did not start") + } +} diff --git a/message.go b/message.go index 56315aa67..7d9ddf66d 100644 --- a/message.go +++ b/message.go @@ -794,10 +794,18 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History if !synchronousStorage { storageCtx = context.WithoutCancel(ctx) } + nonceChanged := false if historySync.CompanionMetaNonce != nil && cli.shouldStoreHistorySyncNonce() { - cli.storeCompanionMetaNonce(storageCtx, historySync.GetCompanionMetaNonce()) + nonceChanged = cli.updateCompanionMetaNonce(historySync.GetCompanionMetaNonce()) } + storeHistorySync := cli.shouldStoreHistorySync() doStorage := func(ctx context.Context) { + if nonceChanged { + cli.persistCompanionMetaNonce(ctx) + } + if !storeHistorySync { + return + } if err := cli.storeNCTSalt(ctx, historySync.GetNctSalt()); err != nil { cli.Log.Warnf("Failed to store NCT salt from history sync: %v", err) } @@ -813,7 +821,7 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History cli.storeGlobalSettings(ctx, historySync.GlobalSettings) } } - if !cli.shouldStoreHistorySync() { + if !storeHistorySync && !nonceChanged { return &historySync, nil } else if synchronousStorage { doStorage(storageCtx) @@ -1118,17 +1126,21 @@ func (cli *Client) storeGlobalSettings(ctx context.Context, settings *waHistoryS } } -func (cli *Client) storeCompanionMetaNonce(ctx context.Context, nonce string) { - if nonce != "" && nonce != cli.Store.CompanionMetaNonce { - cli.Store.CompanionMetaNonce = nonce - err := cli.Store.Save(ctx) - if err != nil { - zerolog.Ctx(ctx).Err(err). - Msg("Failed to save companion meta nonce") - } else { - zerolog.Ctx(ctx).Debug(). - Msg("Saved companion meta nonce") - } +func (cli *Client) updateCompanionMetaNonce(nonce string) bool { + if nonce == "" || nonce == cli.Store.CompanionMetaNonce { + return false + } + cli.Store.CompanionMetaNonce = nonce + return true +} + +func (cli *Client) persistCompanionMetaNonce(ctx context.Context) { + if err := cli.Store.Save(ctx); err != nil { + zerolog.Ctx(ctx).Err(err). + Msg("Failed to save companion meta nonce") + } else { + zerolog.Ctx(ctx).Debug(). + Msg("Saved companion meta nonce") } } From 398f33f99e284694a2a74eb8a3ef599f9cd0d31a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 09:10:11 +0300 Subject: [PATCH 026/163] fix: serialize async history nonce saves --- client.go | 2 + historysync_test.go | 156 +++++++++++++++++++++++++++++++------------- message.go | 18 ++++- upload.go | 4 +- 4 files changed, 132 insertions(+), 48 deletions(-) diff --git a/client.go b/client.go index 1d7b42f10..542c55be3 100644 --- a/client.go +++ b/client.go @@ -142,6 +142,8 @@ type Client struct { DisableHistorySyncReceipt bool DisableHistorySyncStorage bool DisableHistorySyncMediaDelete bool + historySyncNonce atomic.Pointer[string] + historySyncNonceSaveLock sync.Mutex uploadPreKeysLock sync.Mutex lastPreKeyUpload time.Time diff --git a/historysync_test.go b/historysync_test.go index b7578eb49..d59f18a57 100644 --- a/historysync_test.go +++ b/historysync_test.go @@ -4,6 +4,7 @@ import ( "bytes" "compress/zlib" "context" + "sync" "testing" "time" @@ -30,6 +31,75 @@ func (container *historySyncDeviceContainer) PutDevice(ctx context.Context, _ *s func (*historySyncDeviceContainer) DeleteDevice(context.Context, *store.Device) error { return nil } +type orderedHistorySyncDeviceContainer struct { + lock sync.Mutex + calls int + persisted string + firstEntered chan struct{} + secondEntered chan struct{} + secondCommitted chan struct{} + committed chan struct{} +} + +func (container *orderedHistorySyncDeviceContainer) PutDevice(_ context.Context, device *store.Device) error { + container.lock.Lock() + container.calls++ + call := container.calls + nonce := device.CompanionMetaNonce + container.lock.Unlock() + + if call == 1 { + close(container.firstEntered) + select { + case <-container.secondEntered: + <-container.secondCommitted + case <-time.After(time.Second): + } + } else { + close(container.secondEntered) + } + + container.lock.Lock() + container.persisted = nonce + container.lock.Unlock() + if call == 2 { + close(container.secondCommitted) + } + container.committed <- struct{}{} + return nil +} + +func (*orderedHistorySyncDeviceContainer) DeleteDevice(context.Context, *store.Device) error { + return nil +} + +func (container *orderedHistorySyncDeviceContainer) persistedNonce() string { + container.lock.Lock() + defer container.lock.Unlock() + return container.persisted +} + +func historySyncNotificationWithNonce(t *testing.T, nonce string) *waE2E.HistorySyncNotification { + t.Helper() + syncType := waHistorySync.HistorySync_INITIAL_BOOTSTRAP + historyBytes, err := proto.Marshal(&waHistorySync.HistorySync{ + SyncType: &syncType, + CompanionMetaNonce: proto.String(nonce), + }) + if err != nil { + t.Fatal(err) + } + var compressed bytes.Buffer + writer := zlib.NewWriter(&compressed) + if _, err = writer.Write(historyBytes); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + return &waE2E.HistorySyncNotification{InitialHistBootstrapInlinePayload: compressed.Bytes()} +} + func TestHistorySyncReceiptPolicy(t *testing.T) { for _, test := range []struct { name string @@ -79,23 +149,6 @@ func TestHistorySyncDeletionKeepsCompanionNonce(t *testing.T) { } func TestAsyncHistorySyncNoncePersistenceIgnoresCallerCancellation(t *testing.T) { - syncType := waHistorySync.HistorySync_INITIAL_BOOTSTRAP - historyBytes, err := proto.Marshal(&waHistorySync.HistorySync{ - SyncType: &syncType, - CompanionMetaNonce: proto.String("fresh"), - }) - if err != nil { - t.Fatal(err) - } - var compressed bytes.Buffer - writer := zlib.NewWriter(&compressed) - if _, err = writer.Write(historyBytes); err != nil { - t.Fatal(err) - } - if err = writer.Close(); err != nil { - t.Fatal(err) - } - container := &historySyncDeviceContainer{putContextErr: make(chan error, 1)} client := &Client{ DisableHistorySyncStorage: true, @@ -104,9 +157,7 @@ func TestAsyncHistorySyncNoncePersistenceIgnoresCallerCancellation(t *testing.T) } ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err = client.DownloadHistorySync(ctx, &waE2E.HistorySyncNotification{ - InitialHistBootstrapInlinePayload: compressed.Bytes(), - }, false) + _, err := client.DownloadHistorySync(ctx, historySyncNotificationWithNonce(t, "fresh"), false) if err != nil { t.Fatal(err) } @@ -116,23 +167,6 @@ func TestAsyncHistorySyncNoncePersistenceIgnoresCallerCancellation(t *testing.T) } func TestAsyncHistorySyncNoncePersistenceDoesNotBlockDownload(t *testing.T) { - syncType := waHistorySync.HistorySync_INITIAL_BOOTSTRAP - historyBytes, err := proto.Marshal(&waHistorySync.HistorySync{ - SyncType: &syncType, - CompanionMetaNonce: proto.String("fresh"), - }) - if err != nil { - t.Fatal(err) - } - var compressed bytes.Buffer - writer := zlib.NewWriter(&compressed) - if _, err = writer.Write(historyBytes); err != nil { - t.Fatal(err) - } - if err = writer.Close(); err != nil { - t.Fatal(err) - } - container := &historySyncDeviceContainer{ putContextErr: make(chan error, 1), putRelease: make(chan struct{}), @@ -143,27 +177,26 @@ func TestAsyncHistorySyncNoncePersistenceDoesNotBlockDownload(t *testing.T) { Log: waLog.Noop, Store: &store.Device{Container: container}, } + notification := historySyncNotificationWithNonce(t, "fresh") done := make(chan error, 1) go func() { - _, err := client.DownloadHistorySync(context.Background(), &waE2E.HistorySyncNotification{ - InitialHistBootstrapInlinePayload: compressed.Bytes(), - }, false) + _, err := client.DownloadHistorySync(context.Background(), notification, false) done <- err }() select { - case err = <-done: + case err := <-done: if err != nil { t.Fatal(err) } case <-time.After(time.Second): t.Fatal("asynchronous nonce persistence blocked history sync download") } - if client.Store.CompanionMetaNonce != "fresh" { - t.Fatalf("companion meta nonce = %q", client.Store.CompanionMetaNonce) + if nonce := client.currentCompanionMetaNonce(); nonce != "fresh" { + t.Fatalf("companion meta nonce = %q", nonce) } select { - case err = <-container.putContextErr: + case err := <-container.putContextErr: if err != nil { t.Fatalf("nonce persistence inherited caller cancellation: %v", err) } @@ -171,3 +204,38 @@ func TestAsyncHistorySyncNoncePersistenceDoesNotBlockDownload(t *testing.T) { t.Fatal("asynchronous nonce persistence did not start") } } + +func TestAsyncHistorySyncNoncePersistenceKeepsNewestNonce(t *testing.T) { + container := &orderedHistorySyncDeviceContainer{ + firstEntered: make(chan struct{}), + secondEntered: make(chan struct{}), + secondCommitted: make(chan struct{}), + committed: make(chan struct{}, 2), + } + client := &Client{ + DisableHistorySyncStorage: true, + Log: waLog.Noop, + Store: &store.Device{Container: container}, + } + if _, err := client.DownloadHistorySync(context.Background(), historySyncNotificationWithNonce(t, "older"), false); err != nil { + t.Fatal(err) + } + select { + case <-container.firstEntered: + case <-time.After(time.Second): + t.Fatal("first nonce persistence did not start") + } + if _, err := client.DownloadHistorySync(context.Background(), historySyncNotificationWithNonce(t, "newest"), false); err != nil { + t.Fatal(err) + } + for range 2 { + select { + case <-container.committed: + case <-time.After(2 * time.Second): + t.Fatal("nonce persistence did not complete") + } + } + if nonce := container.persistedNonce(); nonce != "newest" { + t.Fatalf("persisted companion meta nonce = %q", nonce) + } +} diff --git a/message.go b/message.go index 7d9ddf66d..2f103ba19 100644 --- a/message.go +++ b/message.go @@ -1127,14 +1127,21 @@ func (cli *Client) storeGlobalSettings(ctx context.Context, settings *waHistoryS } func (cli *Client) updateCompanionMetaNonce(nonce string) bool { - if nonce == "" || nonce == cli.Store.CompanionMetaNonce { + if nonce == "" || nonce == cli.currentCompanionMetaNonce() { return false } - cli.Store.CompanionMetaNonce = nonce + cli.historySyncNonce.Store(&nonce) return true } func (cli *Client) persistCompanionMetaNonce(ctx context.Context) { + cli.historySyncNonceSaveLock.Lock() + defer cli.historySyncNonceSaveLock.Unlock() + nonce := cli.currentCompanionMetaNonce() + if nonce == "" || nonce == cli.Store.CompanionMetaNonce { + return + } + cli.Store.CompanionMetaNonce = nonce if err := cli.Store.Save(ctx); err != nil { zerolog.Ctx(ctx).Err(err). Msg("Failed to save companion meta nonce") @@ -1144,6 +1151,13 @@ func (cli *Client) persistCompanionMetaNonce(ctx context.Context) { } } +func (cli *Client) currentCompanionMetaNonce() string { + if nonce := cli.historySyncNonce.Load(); nonce != nil { + return *nonce + } + return cli.Store.CompanionMetaNonce +} + func (cli *Client) storeHistoricalPNLIDMappings(ctx context.Context, mappings []*waHistorySync.PhoneNumberToLIDMapping) { lidPairs := make([]store.LIDMapping, 0, len(mappings)) for _, mapping := range mappings { diff --git a/upload.go b/upload.go index cd4f19350..9210e0a9b 100644 --- a/upload.go +++ b/upload.go @@ -308,8 +308,8 @@ func (cli *Client) DeleteMedia(ctx context.Context, appInfo MediaType, directPat req.Header.Set("Origin", socket.Origin) req.Header.Set("Referer", socket.Origin+"/") - if cmn := cli.Store.CompanionMetaNonce; cmn != "" && encHandle != "" { - req.Header.Set("Companion_User_Secret", cli.Store.CompanionMetaNonce) + if cmn := cli.currentCompanionMetaNonce(); cmn != "" && encHandle != "" { + req.Header.Set("Companion_User_Secret", cmn) } httpResp, err := cli.mediaHTTP.Do(req) From 96d7518214739b1806e7574d8e489495cb4b54fa Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 09:03:38 +0300 Subject: [PATCH 027/163] Add typed business profile mutations --- business_profile.go | 292 +++++++++++++++++++++++++++++++++++++++ business_profile_test.go | 182 ++++++++++++++++++++++++ types/user.go | 23 +++ user.go | 12 ++ 4 files changed, 509 insertions(+) create mode 100644 business_profile.go create mode 100644 business_profile_test.go diff --git a/business_profile.go b/business_profile.go new file mode 100644 index 000000000..8b07b3820 --- /dev/null +++ b/business_profile.go @@ -0,0 +1,292 @@ +package whatsmeow + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/mail" + "net/url" + "strconv" + "strings" + "time" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/socket" + "go.mau.fi/whatsmeow/types" +) + +const maxBusinessCoverPhotoBytes = 5 * 1024 * 1024 + +type businessCoverUploadResponse struct { + MetaHMAC string `json:"meta_hmac"` + FBID string `json:"fbid"` + Timestamp string `json:"ts"` +} + +var businessProfileDays = map[string]struct{}{ + "sun": {}, "mon": {}, "tue": {}, "wed": {}, "thu": {}, "fri": {}, "sat": {}, +} + +var businessProfileHourModes = map[string]struct{}{ + "specific_hours": {}, "open_24h": {}, "appointment_only": {}, +} + +func buildBusinessProfileDelta(update types.BusinessProfileUpdate) (waBinary.Node, error) { + if update.Address == nil && update.Email == nil && update.Description == nil && update.Websites == nil && update.Hours == nil { + return waBinary.Node{}, fmt.Errorf("business profile update is empty") + } + if update.Address != nil && len(*update.Address) > 512 { + return waBinary.Node{}, fmt.Errorf("business address exceeds 512 bytes") + } + if update.Description != nil && len(*update.Description) > 1024 { + return waBinary.Node{}, fmt.Errorf("business description exceeds 1024 bytes") + } + if update.Email != nil { + if len(*update.Email) > 320 { + return waBinary.Node{}, fmt.Errorf("business email exceeds 320 bytes") + } + if *update.Email != "" { + parsed, err := mail.ParseAddress(*update.Email) + if err != nil || parsed.Address != *update.Email { + return waBinary.Node{}, fmt.Errorf("business email is invalid") + } + } + } + + children := make([]waBinary.Node, 0, 7) + if update.Address != nil { + children = append(children, waBinary.Node{Tag: "address", Content: []byte(*update.Address)}) + } + if update.Email != nil { + children = append(children, waBinary.Node{Tag: "email", Content: []byte(*update.Email)}) + } + if update.Description != nil { + children = append(children, waBinary.Node{Tag: "description", Content: []byte(*update.Description)}) + } + if update.Websites != nil { + if len(*update.Websites) < 1 || len(*update.Websites) > 2 { + return waBinary.Node{}, fmt.Errorf("business profile must contain between 1 and 2 websites") + } + for _, website := range *update.Websites { + if len(website) > 2048 { + return waBinary.Node{}, fmt.Errorf("business website exceeds 2048 bytes") + } + parsed, err := url.ParseRequestURI(website) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return waBinary.Node{}, fmt.Errorf("business website %q is not an absolute HTTP URL", website) + } + children = append(children, waBinary.Node{Tag: "website", Content: []byte(website)}) + } + } + if update.Hours != nil { + hours, err := buildBusinessHoursNode(*update.Hours) + if err != nil { + return waBinary.Node{}, err + } + children = append(children, hours) + } + + return waBinary.Node{ + Tag: "business_profile", + Attrs: waBinary.Attrs{ + "v": "3", + "mutation_type": "delta", + }, + Content: children, + }, nil +} + +func buildBusinessHoursNode(update types.BusinessHoursUpdate) (waBinary.Node, error) { + if update.TimeZone == "" || len(update.TimeZone) > 128 { + return waBinary.Node{}, fmt.Errorf("business hours timezone is invalid") + } + if _, err := time.LoadLocation(update.TimeZone); err != nil { + return waBinary.Node{}, fmt.Errorf("business hours timezone is invalid: %w", err) + } + if len(update.Days) < 1 || len(update.Days) > 7 { + return waBinary.Node{}, fmt.Errorf("business hours must contain between 1 and 7 days") + } + + seen := make(map[string]struct{}, len(update.Days)) + configs := make([]waBinary.Node, 0, len(update.Days)) + for _, day := range update.Days { + if _, ok := businessProfileDays[day.DayOfWeek]; !ok { + return waBinary.Node{}, fmt.Errorf("invalid business hours day %q", day.DayOfWeek) + } + if _, ok := seen[day.DayOfWeek]; ok { + return waBinary.Node{}, fmt.Errorf("duplicate business hours day %q", day.DayOfWeek) + } + seen[day.DayOfWeek] = struct{}{} + if _, ok := businessProfileHourModes[day.Mode]; !ok { + return waBinary.Node{}, fmt.Errorf("invalid business hours mode %q", day.Mode) + } + + attrs := waBinary.Attrs{"day_of_week": day.DayOfWeek, "mode": day.Mode} + if day.Mode == "specific_hours" { + if day.OpenTime < 0 || day.OpenTime > 1439 || day.CloseTime < 0 || day.CloseTime > 1439 || day.OpenTime == day.CloseTime { + return waBinary.Node{}, fmt.Errorf("invalid specific hours for %s", day.DayOfWeek) + } + attrs["open_time"] = strconv.Itoa(day.OpenTime) + attrs["close_time"] = strconv.Itoa(day.CloseTime) + } else if day.OpenTime != 0 || day.CloseTime != 0 { + return waBinary.Node{}, fmt.Errorf("%s mode does not accept open or close times", day.Mode) + } + configs = append(configs, waBinary.Node{Tag: "business_hours_config", Attrs: attrs}) + } + + return waBinary.Node{ + Tag: "business_hours", + Attrs: waBinary.Attrs{"timezone": strings.TrimSpace(update.TimeZone)}, + Content: configs, + }, nil +} + +func (cli *Client) UpdateBusinessProfile(ctx context.Context, update types.BusinessProfileUpdate) error { + node, err := buildBusinessProfileDelta(update) + if err != nil { + return err + } + _, err = cli.sendIQ(ctx, infoQuery{ + Namespace: "w:biz", + Type: iqSet, + To: types.ServerJID, + Content: []waBinary.Node{node}, + }) + if err != nil { + return fmt.Errorf("failed to update business profile: %w", err) + } + return nil +} + +func validateBusinessCoverPhoto(image []byte) ([]byte, error) { + if len(image) == 0 { + return nil, fmt.Errorf("business cover photo is empty") + } + if len(image) > maxBusinessCoverPhotoBytes { + return nil, fmt.Errorf("business cover photo exceeds %d bytes", maxBusinessCoverPhotoBytes) + } + mimeType := http.DetectContentType(image) + if mimeType != "image/jpeg" && mimeType != "image/png" { + return nil, fmt.Errorf("business cover photo must be JPEG or PNG") + } + hash := sha256.Sum256(image) + return hash[:], nil +} + +func (cli *Client) uploadBusinessCoverPhoto(ctx context.Context, image []byte) (businessCoverUploadResponse, error) { + var response businessCoverUploadResponse + hash, err := validateBusinessCoverPhoto(image) + if err != nil { + return response, err + } + mediaConn, err := cli.refreshMediaConn(ctx, false) + if err != nil { + return response, fmt.Errorf("failed to refresh media connections: %w", err) + } + if len(mediaConn.Hosts) == 0 { + return response, fmt.Errorf("media connection response contained no upload hosts") + } + + token := base64.URLEncoding.EncodeToString(hash) + query := url.Values{"auth": {mediaConn.Auth}, "token": {token}} + uploadURL := url.URL{ + Scheme: "https", + Host: mediaConn.Hosts[0].Hostname, + Path: "/pps/biz-cover-photo/" + token, + RawQuery: query.Encode(), + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL.String(), bytes.NewReader(image)) + if err != nil { + return response, fmt.Errorf("failed to prepare business cover photo upload: %w", err) + } + request.ContentLength = int64(len(image)) + request.Header.Set("Content-Type", http.DetectContentType(image)) + request.Header.Set("Origin", socket.Origin) + request.Header.Set("Referer", socket.Origin+"/") + + httpResponse, err := cli.mediaHTTP.Do(request) + if err != nil { + return response, fmt.Errorf("failed to upload business cover photo: %w", err) + } + defer drainAndClose(httpResponse.Body) + if httpResponse.StatusCode != http.StatusOK { + return response, fmt.Errorf("business cover photo upload failed with status code %d", httpResponse.StatusCode) + } + if err = json.NewDecoder(httpResponse.Body).Decode(&response); err != nil { + return response, fmt.Errorf("failed to parse business cover photo upload response: %w", err) + } + if _, err = buildBusinessCoverPhotoUpdateNode(response); err != nil { + return response, err + } + return response, nil +} + +func buildBusinessCoverPhotoUpdateNode(response businessCoverUploadResponse) (waBinary.Node, error) { + if response.MetaHMAC == "" || response.FBID == "" || response.Timestamp == "" { + return waBinary.Node{}, fmt.Errorf("business cover photo upload response is incomplete") + } + return waBinary.Node{ + Tag: "cover_photo", + Attrs: waBinary.Attrs{ + "id": response.FBID, + "op": "update", + "token": response.MetaHMAC, + "ts": response.Timestamp, + }, + }, nil +} + +func buildBusinessCoverPhotoDeleteNode(coverID string) (waBinary.Node, error) { + if strings.TrimSpace(coverID) == "" { + return waBinary.Node{}, fmt.Errorf("business cover photo ID is empty") + } + if len(coverID) > 256 { + return waBinary.Node{}, fmt.Errorf("business cover photo ID exceeds 256 bytes") + } + return waBinary.Node{ + Tag: "cover_photo", + Attrs: waBinary.Attrs{"id": coverID, "op": "delete"}, + }, nil +} + +func (cli *Client) SetBusinessCoverPhoto(ctx context.Context, image []byte) (string, error) { + response, err := cli.uploadBusinessCoverPhoto(ctx, image) + if err != nil { + return "", err + } + node, err := buildBusinessCoverPhotoUpdateNode(response) + if err != nil { + return "", err + } + _, err = cli.sendIQ(ctx, infoQuery{ + Namespace: "w:biz", + Type: iqSet, + To: types.ServerJID, + Content: []waBinary.Node{node}, + }) + if err != nil { + return "", fmt.Errorf("failed to set business cover photo: %w", err) + } + return response.FBID, nil +} + +func (cli *Client) DeleteBusinessCoverPhoto(ctx context.Context, coverID string) error { + node, err := buildBusinessCoverPhotoDeleteNode(coverID) + if err != nil { + return err + } + _, err = cli.sendIQ(ctx, infoQuery{ + Namespace: "w:biz", + Type: iqSet, + To: types.ServerJID, + Content: []waBinary.Node{node}, + }) + if err != nil { + return fmt.Errorf("failed to delete business cover photo: %w", err) + } + return nil +} diff --git a/business_profile_test.go b/business_profile_test.go new file mode 100644 index 000000000..41a5d5192 --- /dev/null +++ b/business_profile_test.go @@ -0,0 +1,182 @@ +package whatsmeow + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +func profileString(value string) *string { + return &value +} + +func TestBuildBusinessProfileDelta(t *testing.T) { + websites := []string{"https://example.test", "https://shop.example.test/catalog"} + update := types.BusinessProfileUpdate{ + Description: profileString("Synthetic tea shop"), + Address: profileString("1 Test Street"), + Email: profileString("tea@example.test"), + Websites: &websites, + Hours: &types.BusinessHoursUpdate{ + TimeZone: "Asia/Beirut", + Days: []types.BusinessHoursDay{ + {DayOfWeek: "mon", Mode: "specific_hours", OpenTime: 540, CloseTime: 1020}, + {DayOfWeek: "sun", Mode: "appointment_only"}, + }, + }, + } + + node, err := buildBusinessProfileDelta(update) + if err != nil { + t.Fatal(err) + } + if node.Tag != "business_profile" || node.AttrGetter().String("v") != "3" || node.AttrGetter().String("mutation_type") != "delta" { + t.Fatalf("unexpected root node: %#v", node) + } + if got := string(node.GetChildByTag("description").Content.([]byte)); got != "Synthetic tea shop" { + t.Fatalf("description = %q", got) + } + websiteNodes := node.GetChildrenByTag("website") + if len(websiteNodes) != 2 || string(websiteNodes[1].Content.([]byte)) != websites[1] { + t.Fatalf("unexpected websites: %#v", websiteNodes) + } + hours := node.GetChildByTag("business_hours") + configs := hours.GetChildrenByTag("business_hours_config") + if hours.AttrGetter().String("timezone") != "Asia/Beirut" || len(configs) != 2 { + t.Fatalf("unexpected business hours: %#v", hours) + } + attrs := configs[0].AttrGetter() + if attrs.String("day_of_week") != "mon" || attrs.String("mode") != "specific_hours" || attrs.String("open_time") != "540" || attrs.String("close_time") != "1020" { + t.Fatalf("unexpected specific hours: %#v", configs[0]) + } +} + +func TestBuildBusinessProfileDeltaRejectsInvalidInput(t *testing.T) { + tooManyWebsites := []string{"https://one.test", "https://two.test", "https://three.test"} + tests := []types.BusinessProfileUpdate{ + {}, + {Description: profileString(strings.Repeat("d", 1025))}, + {Email: profileString("not-an-email")}, + {Websites: &tooManyWebsites}, + {Websites: &[]string{"file:///tmp/profile"}}, + {Hours: &types.BusinessHoursUpdate{TimeZone: "not/a-zone", Days: []types.BusinessHoursDay{{DayOfWeek: "mon", Mode: "open_24h"}}}}, + {Hours: &types.BusinessHoursUpdate{TimeZone: "UTC", Days: []types.BusinessHoursDay{{DayOfWeek: "mon", Mode: "specific_hours", OpenTime: -1, CloseTime: 100}}}}, + {Hours: &types.BusinessHoursUpdate{TimeZone: "UTC", Days: []types.BusinessHoursDay{{DayOfWeek: "mon", Mode: "open_24h"}, {DayOfWeek: "mon", Mode: "appointment_only"}}}}, + } + for i, update := range tests { + if _, err := buildBusinessProfileDelta(update); err == nil { + t.Fatalf("case %d unexpectedly passed", i) + } + } +} + +func TestParseBusinessProfilePreservesEditableFields(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + node := waBinary.Node{ + Tag: "business_profile", + Content: []waBinary.Node{{ + Tag: "profile", + Attrs: waBinary.Attrs{"jid": jid}, + Content: []waBinary.Node{ + {Tag: "address", Content: []byte("1 Test Street")}, + {Tag: "email", Content: []byte("tea@example.test")}, + {Tag: "description", Content: []byte("Synthetic tea shop")}, + {Tag: "website", Content: []byte("https://example.test")}, + {Tag: "website", Content: []byte("https://shop.example.test")}, + {Tag: "cover_photo", Attrs: waBinary.Attrs{"id": "cover-100"}}, + }, + }}, + } + + profile, err := (&Client{}).parseBusinessProfile(&node) + if err != nil { + t.Fatal(err) + } + if profile.Description != "Synthetic tea shop" || profile.CoverPhotoID != "cover-100" { + t.Fatalf("unexpected profile fields: %#v", profile) + } + if len(profile.Websites) != 2 || profile.Websites[1] != "https://shop.example.test" { + t.Fatalf("unexpected websites: %#v", profile.Websites) + } +} + +func TestUploadBusinessCoverPhotoUsesPlaintextPPSPath(t *testing.T) { + image := append([]byte("\x89PNG\r\n\x1a\n"), []byte("synthetic-image")...) + hash := sha256.Sum256(image) + expectedToken := base64.URLEncoding.EncodeToString(hash[:]) + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/pps/biz-cover-photo/"+expectedToken { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.RequestURI()) + } + if r.URL.Query().Get("auth") != "synthetic-auth" || r.URL.Query().Get("token") != expectedToken { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + body, err := io.ReadAll(r.Body) + if err != nil || string(body) != string(image) { + t.Fatalf("unexpected body: %q, error: %v", body, err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"meta_hmac":"cover-token","fbid":"cover-100","ts":"1720000000"}`) + })) + defer server.Close() + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := &Client{ + mediaHTTP: server.Client(), + mediaConnCache: &MediaConn{ + Auth: "synthetic-auth", + TTL: 3600, + FetchedAt: time.Now(), + Hosts: []MediaConnHost{{Hostname: serverURL.Host}}, + }, + } + + response, err := client.uploadBusinessCoverPhoto(context.Background(), image) + if err != nil { + t.Fatal(err) + } + if response.MetaHMAC != "cover-token" || response.FBID != "cover-100" || response.Timestamp != "1720000000" { + t.Fatalf("unexpected response: %#v", response) + } +} + +func TestBusinessCoverPhotoValidationAndNodes(t *testing.T) { + if _, err := validateBusinessCoverPhoto([]byte("not an image")); err == nil { + t.Fatal("expected unsupported image error") + } + if _, err := validateBusinessCoverPhoto(make([]byte, maxBusinessCoverPhotoBytes+1)); err == nil { + t.Fatal("expected oversized image error") + } + setNode, err := buildBusinessCoverPhotoUpdateNode(businessCoverUploadResponse{MetaHMAC: "token", FBID: "cover-100", Timestamp: "1"}) + if err != nil { + t.Fatal(err) + } + attrs := setNode.AttrGetter() + if setNode.Tag != "cover_photo" || attrs.String("op") != "update" || attrs.String("id") != "cover-100" || attrs.String("token") != "token" || attrs.String("ts") != "1" { + t.Fatalf("unexpected set node: %#v", setNode) + } + deleteNode, err := buildBusinessCoverPhotoDeleteNode("cover-100") + if err != nil { + t.Fatal(err) + } + if deleteNode.AttrGetter().String("op") != "delete" || deleteNode.AttrGetter().String("id") != "cover-100" { + t.Fatalf("unexpected delete node: %#v", deleteNode) + } + if _, err = buildBusinessCoverPhotoDeleteNode(""); err == nil { + t.Fatal("expected empty cover ID error") + } +} diff --git a/types/user.go b/types/user.go index 805f70e7e..f79d117d2 100644 --- a/types/user.go +++ b/types/user.go @@ -211,6 +211,26 @@ type BusinessHoursConfig struct { CloseTime string } +type BusinessHoursDay struct { + DayOfWeek string + Mode string + OpenTime int + CloseTime int +} + +type BusinessHoursUpdate struct { + TimeZone string + Days []BusinessHoursDay +} + +type BusinessProfileUpdate struct { + Address *string + Email *string + Description *string + Websites *[]string + Hours *BusinessHoursUpdate +} + // Category contains a WhatsApp business category. type Category struct { ID string @@ -222,6 +242,9 @@ type BusinessProfile struct { JID JID Address string Email string + Description string + Websites []string + CoverPhotoID string Categories []Category ProfileOptions map[string]string BusinessHoursTimeZone string diff --git a/user.go b/user.go index ccac26e25..f307c5521 100644 --- a/user.go +++ b/user.go @@ -390,6 +390,15 @@ func (cli *Client) parseBusinessProfile(node *waBinary.Node) (*types.BusinessPro } address, _ := profileNode.GetChildByTag("address").Content.([]byte) email, _ := profileNode.GetChildByTag("email").Content.([]byte) + description, _ := profileNode.GetChildByTag("description").Content.([]byte) + websiteNodes := profileNode.GetChildrenByTag("website") + websites := make([]string, 0, len(websiteNodes)) + for _, websiteNode := range websiteNodes { + website, _ := websiteNode.Content.([]byte) + websites = append(websites, string(website)) + } + coverPhoto := profileNode.GetChildByTag("cover_photo") + coverPhotoID := coverPhoto.AttrGetter().String("id") businessHour := profileNode.GetChildByTag("business_hours") businessHourTimezone := businessHour.AttrGetter().String("timezone") businessHoursConfigs := businessHour.GetChildren() @@ -433,6 +442,9 @@ func (cli *Client) parseBusinessProfile(node *waBinary.Node) (*types.BusinessPro JID: jid, Email: string(email), Address: string(address), + Description: string(description), + Websites: websites, + CoverPhotoID: coverPhotoID, Categories: categories, ProfileOptions: profileOptions, BusinessHoursTimeZone: businessHourTimezone, From f8dcb552e1b94275c5bfcee171fc2090cbf8ca69 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 09:42:07 +0300 Subject: [PATCH 028/163] Redact business profile data from stanza logs --- binary/xml.go | 20 ++++++++++++++++++++ binary/xml_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 binary/xml_test.go diff --git a/binary/xml.go b/binary/xml.go index 486afd912..bdece7c8b 100644 --- a/binary/xml.go +++ b/binary/xml.go @@ -21,6 +21,19 @@ var ( MaxBytesToPrintAsHex = 128 ) +func sensitiveXMLAttribute(key string) bool { + return key == "auth" || key == "token" +} + +func sensitiveXMLNodeContent(tag string) bool { + switch tag { + case "address", "description", "email", "website": + return true + default: + return false + } +} + // String converts the Node to its XML representation func (n Node) String() string { content := n.contentString() @@ -41,6 +54,9 @@ func (n *Node) attributeString() string { stringAttrs := make([]string, len(n.Attrs)+1) i := 1 for key, value := range n.Attrs { + if sensitiveXMLAttribute(key) { + value = "[redacted]" + } stringAttrs[i] = fmt.Sprintf(`%s="%v"`, key, value) i++ } @@ -69,6 +85,10 @@ func (n *Node) contentString() []string { split = append(split, strings.Split(item.String(), "\n")...) } case []byte: + if sensitiveXMLNodeContent(n.Tag) { + split = append(split, "[redacted]") + break + } if strContent := printable(content); len(strContent) > 0 { if IndentXML { split = append(split, strings.Split(string(content), "\n")...) diff --git a/binary/xml_test.go b/binary/xml_test.go new file mode 100644 index 000000000..edf061e6b --- /dev/null +++ b/binary/xml_test.go @@ -0,0 +1,36 @@ +package binary + +import ( + "strings" + "testing" +) + +func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { + node := Node{ + Tag: "business_profile", + Attrs: Attrs{"auth": "media-secret", "token": "upload-secret"}, + Content: []Node{ + {Tag: "address", Content: []byte("12 Private Street")}, + {Tag: "email", Content: []byte("owner@example.test")}, + {Tag: "description", Content: []byte("Private description")}, + {Tag: "website", Content: []byte("https://private.example.test")}, + }, + } + + logged := node.String() + for _, sensitive := range []string{ + "media-secret", + "upload-secret", + "12 Private Street", + "owner@example.test", + "Private description", + "https://private.example.test", + } { + if strings.Contains(logged, sensitive) { + t.Fatalf("logged sensitive value %q: %s", sensitive, logged) + } + } + if strings.Count(logged, "[redacted]") != 6 { + t.Fatalf("unexpected redacted node: %s", logged) + } +} From 5cf55e1319327888028ed7218bb22d7b60ba655c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 00:58:28 +0300 Subject: [PATCH 029/163] fix: preserve business profile semantics --- business_profile.go | 7 +++++-- business_profile_test.go | 16 ++++++++++++++++ errors.go | 3 ++- errors_iq_test.go | 16 ++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 errors_iq_test.go diff --git a/business_profile.go b/business_profile.go index 8b07b3820..44a653f68 100644 --- a/business_profile.go +++ b/business_profile.go @@ -68,8 +68,11 @@ func buildBusinessProfileDelta(update types.BusinessProfileUpdate) (waBinary.Nod children = append(children, waBinary.Node{Tag: "description", Content: []byte(*update.Description)}) } if update.Websites != nil { - if len(*update.Websites) < 1 || len(*update.Websites) > 2 { - return waBinary.Node{}, fmt.Errorf("business profile must contain between 1 and 2 websites") + if len(*update.Websites) > 2 { + return waBinary.Node{}, fmt.Errorf("business profile must contain at most 2 websites") + } + if len(*update.Websites) == 0 { + children = append(children, waBinary.Node{Tag: "website", Content: []byte{}}) } for _, website := range *update.Websites { if len(website) > 2048 { diff --git a/business_profile_test.go b/business_profile_test.go index 41a5d5192..7470aba1a 100644 --- a/business_profile_test.go +++ b/business_profile_test.go @@ -62,6 +62,22 @@ func TestBuildBusinessProfileDelta(t *testing.T) { } } +func TestBuildBusinessProfileDeltaClearsWebsites(t *testing.T) { + websites := []string{} + node, err := buildBusinessProfileDelta(types.BusinessProfileUpdate{Websites: &websites}) + if err != nil { + t.Fatal(err) + } + websiteNodes := node.GetChildrenByTag("website") + if len(websiteNodes) != 1 { + t.Fatalf("website nodes = %d, want removal node", len(websiteNodes)) + } + content, ok := websiteNodes[0].Content.([]byte) + if !ok || len(content) != 0 { + t.Fatalf("website removal content = %#v", websiteNodes[0].Content) + } +} + func TestBuildBusinessProfileDeltaRejectsInvalidInput(t *testing.T) { tooManyWebsites := []string{"https://one.test", "https://two.test", "https://three.test"} tests := []types.BusinessProfileUpdate{ diff --git a/errors.go b/errors.go index 72e94b6e6..8ace2e7d7 100644 --- a/errors.go +++ b/errors.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "net/http" + "reflect" waBinary "go.mau.fi/whatsmeow/binary" ) @@ -231,7 +232,7 @@ func (iqe *IQError) Is(other error) bool { } else if iqe.Code != 0 && otherIQE.Code != 0 { return otherIQE.Code == iqe.Code && otherIQE.Text == iqe.Text } else if iqe.ErrorNode != nil && otherIQE.ErrorNode != nil { - return iqe.ErrorNode.String() == otherIQE.ErrorNode.String() + return reflect.DeepEqual(iqe.ErrorNode, otherIQE.ErrorNode) } else { return false } diff --git a/errors_iq_test.go b/errors_iq_test.go new file mode 100644 index 000000000..fd625f3d9 --- /dev/null +++ b/errors_iq_test.go @@ -0,0 +1,16 @@ +package whatsmeow + +import ( + "errors" + "testing" + + waBinary "go.mau.fi/whatsmeow/binary" +) + +func TestIQErrorIsDistinguishesSensitiveAttributes(t *testing.T) { + first := &IQError{ErrorNode: &waBinary.Node{Tag: "error", Attrs: waBinary.Attrs{"token": "first"}}} + second := &IQError{ErrorNode: &waBinary.Node{Tag: "error", Attrs: waBinary.Attrs{"token": "second"}}} + if errors.Is(first, second) { + t.Fatal("errors with distinct sensitive attributes compare equal") + } +} From 677fb8a1a05ea1d5232d21c9f3f35bb40d795541 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:35:20 +0300 Subject: [PATCH 030/163] fix: harden business profile updates --- business_profile.go | 7 +++++-- business_profile_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/business_profile.go b/business_profile.go index 44a653f68..e6fa842d0 100644 --- a/business_profile.go +++ b/business_profile.go @@ -110,8 +110,8 @@ func buildBusinessHoursNode(update types.BusinessHoursUpdate) (waBinary.Node, er if _, err := time.LoadLocation(update.TimeZone); err != nil { return waBinary.Node{}, fmt.Errorf("business hours timezone is invalid: %w", err) } - if len(update.Days) < 1 || len(update.Days) > 7 { - return waBinary.Node{}, fmt.Errorf("business hours must contain between 1 and 7 days") + if len(update.Days) > 7 { + return waBinary.Node{}, fmt.Errorf("business hours must contain at most 7 days") } seen := make(map[string]struct{}, len(update.Days)) @@ -213,6 +213,9 @@ func (cli *Client) uploadBusinessCoverPhoto(ctx context.Context, image []byte) ( httpResponse, err := cli.mediaHTTP.Do(request) if err != nil { + if urlErr, ok := err.(*url.Error); ok { + err = urlErr.Err + } return response, fmt.Errorf("failed to upload business cover photo: %w", err) } defer drainAndClose(httpResponse.Body) diff --git a/business_profile_test.go b/business_profile_test.go index 7470aba1a..8b4d34d7f 100644 --- a/business_profile_test.go +++ b/business_profile_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "errors" "fmt" "io" "net/http" @@ -78,6 +79,18 @@ func TestBuildBusinessProfileDeltaClearsWebsites(t *testing.T) { } } +func TestBuildBusinessProfileDeltaClearsHours(t *testing.T) { + hours := types.BusinessHoursUpdate{TimeZone: "UTC"} + node, err := buildBusinessProfileDelta(types.BusinessProfileUpdate{Hours: &hours}) + if err != nil { + t.Fatal(err) + } + hoursNode := node.GetChildByTag("business_hours") + if hoursNode.AttrGetter().String("timezone") != "UTC" || len(hoursNode.GetChildren()) != 0 { + t.Fatalf("unexpected business hours removal node: %#v", hoursNode) + } +} + func TestBuildBusinessProfileDeltaRejectsInvalidInput(t *testing.T) { tooManyWebsites := []string{"https://one.test", "https://two.test", "https://three.test"} tests := []types.BusinessProfileUpdate{ @@ -170,6 +183,32 @@ func TestUploadBusinessCoverPhotoUsesPlaintextPPSPath(t *testing.T) { } } +type businessCoverRoundTripFunc func(*http.Request) (*http.Response, error) + +func (roundTrip businessCoverRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) +} + +func TestUploadBusinessCoverPhotoRedactsTransportURL(t *testing.T) { + sentinel := errors.New("synthetic transport failure") + client := &Client{ + mediaHTTP: &http.Client{Transport: businessCoverRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, sentinel + })}, + mediaConnCache: &MediaConn{ + Auth: "sensitive-auth", TTL: 3600, FetchedAt: time.Now(), Hosts: []MediaConnHost{{Hostname: "upload.invalid"}}, + }, + } + image := append([]byte("\x89PNG\r\n\x1a\n"), []byte("synthetic-image")...) + _, err := client.uploadBusinessCoverPhoto(context.Background(), image) + if !errors.Is(err, sentinel) { + t.Fatalf("transport cause was not preserved: %v", err) + } + if strings.Contains(err.Error(), "sensitive-auth") { + t.Fatalf("transport error exposed auth query: %v", err) + } +} + func TestBusinessCoverPhotoValidationAndNodes(t *testing.T) { if _, err := validateBusinessCoverPhoto([]byte("not an image")); err == nil { t.Fatal("expected unsupported image error") From 4a7375fd1a172d1b08c58e0b726886947d84bb81 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:19:25 +0300 Subject: [PATCH 031/163] fix: wrap cover photo profile mutations --- business_profile.go | 10 +++++++--- business_profile_test.go | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/business_profile.go b/business_profile.go index e6fa842d0..d282837ea 100644 --- a/business_profile.go +++ b/business_profile.go @@ -93,6 +93,10 @@ func buildBusinessProfileDelta(update types.BusinessProfileUpdate) (waBinary.Nod children = append(children, hours) } + return buildBusinessProfileMutationNode(children...), nil +} + +func buildBusinessProfileMutationNode(children ...waBinary.Node) waBinary.Node { return waBinary.Node{ Tag: "business_profile", Attrs: waBinary.Attrs{ @@ -100,7 +104,7 @@ func buildBusinessProfileDelta(update types.BusinessProfileUpdate) (waBinary.Nod "mutation_type": "delta", }, Content: children, - }, nil + } } func buildBusinessHoursNode(update types.BusinessHoursUpdate) (waBinary.Node, error) { @@ -272,7 +276,7 @@ func (cli *Client) SetBusinessCoverPhoto(ctx context.Context, image []byte) (str Namespace: "w:biz", Type: iqSet, To: types.ServerJID, - Content: []waBinary.Node{node}, + Content: []waBinary.Node{buildBusinessProfileMutationNode(node)}, }) if err != nil { return "", fmt.Errorf("failed to set business cover photo: %w", err) @@ -289,7 +293,7 @@ func (cli *Client) DeleteBusinessCoverPhoto(ctx context.Context, coverID string) Namespace: "w:biz", Type: iqSet, To: types.ServerJID, - Content: []waBinary.Node{node}, + Content: []waBinary.Node{buildBusinessProfileMutationNode(node)}, }) if err != nil { return fmt.Errorf("failed to delete business cover photo: %w", err) diff --git a/business_profile_test.go b/business_profile_test.go index 8b4d34d7f..8d8cb11a2 100644 --- a/business_profile_test.go +++ b/business_profile_test.go @@ -224,6 +224,11 @@ func TestBusinessCoverPhotoValidationAndNodes(t *testing.T) { if setNode.Tag != "cover_photo" || attrs.String("op") != "update" || attrs.String("id") != "cover-100" || attrs.String("token") != "token" || attrs.String("ts") != "1" { t.Fatalf("unexpected set node: %#v", setNode) } + setDelta := buildBusinessProfileMutationNode(setNode) + setChildren := setDelta.GetChildren() + if setDelta.Tag != "business_profile" || setDelta.AttrGetter().String("mutation_type") != "delta" || len(setChildren) != 1 || setChildren[0].Tag != "cover_photo" { + t.Fatalf("unexpected set delta: %#v", setDelta) + } deleteNode, err := buildBusinessCoverPhotoDeleteNode("cover-100") if err != nil { t.Fatal(err) @@ -231,6 +236,11 @@ func TestBusinessCoverPhotoValidationAndNodes(t *testing.T) { if deleteNode.AttrGetter().String("op") != "delete" || deleteNode.AttrGetter().String("id") != "cover-100" { t.Fatalf("unexpected delete node: %#v", deleteNode) } + deleteDelta := buildBusinessProfileMutationNode(deleteNode) + deleteChildren := deleteDelta.GetChildren() + if deleteDelta.Tag != "business_profile" || len(deleteChildren) != 1 || deleteChildren[0].Tag != "cover_photo" { + t.Fatalf("unexpected delete delta: %#v", deleteDelta) + } if _, err = buildBusinessCoverPhotoDeleteNode(""); err == nil { t.Fatal("expected empty cover ID error") } From c2df0eda0a23155d67587dd4cd6d1dd5740f70ba Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 04:26:33 +0300 Subject: [PATCH 032/163] fix: normalize IQ error node semantics --- errors.go | 30 +++++++++++++++++++++++++++++- errors_iq_test.go | 16 ++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/errors.go b/errors.go index 8ace2e7d7..80bce7cad 100644 --- a/errors.go +++ b/errors.go @@ -232,12 +232,40 @@ func (iqe *IQError) Is(other error) bool { } else if iqe.Code != 0 && otherIQE.Code != 0 { return otherIQE.Code == iqe.Code && otherIQE.Text == iqe.Text } else if iqe.ErrorNode != nil && otherIQE.ErrorNode != nil { - return reflect.DeepEqual(iqe.ErrorNode, otherIQE.ErrorNode) + return reflect.DeepEqual(normalizeIQErrorNode(iqe.ErrorNode), normalizeIQErrorNode(otherIQE.ErrorNode)) } else { return false } } +func normalizeIQErrorNode(node *waBinary.Node) *waBinary.Node { + if node == nil { + return nil + } + normalized := *node + if len(node.Attrs) > 0 { + normalized.Attrs = make(waBinary.Attrs, len(node.Attrs)) + for key, value := range node.Attrs { + if value != nil && value != "" { + normalized.Attrs[key] = value + } + } + if len(normalized.Attrs) == 0 { + normalized.Attrs = nil + } + } else { + normalized.Attrs = nil + } + if children, ok := node.Content.([]waBinary.Node); ok { + normalizedChildren := make([]waBinary.Node, len(children)) + for index := range children { + normalizedChildren[index] = *normalizeIQErrorNode(&children[index]) + } + normalized.Content = normalizedChildren + } + return &normalized +} + // ElementMissingError is returned by various functions that parse XML elements when a required element is missing. type ElementMissingError struct { Tag string diff --git a/errors_iq_test.go b/errors_iq_test.go index fd625f3d9..1425ef8a2 100644 --- a/errors_iq_test.go +++ b/errors_iq_test.go @@ -14,3 +14,19 @@ func TestIQErrorIsDistinguishesSensitiveAttributes(t *testing.T) { t.Fatal("errors with distinct sensitive attributes compare equal") } } + +func TestIQErrorIsNormalizesEquivalentAttributes(t *testing.T) { + decoded := &IQError{ErrorNode: &waBinary.Node{Tag: "error"}} + handBuilt := &IQError{ErrorNode: &waBinary.Node{ + Tag: "error", + Attrs: waBinary.Attrs{"ignored-empty": "", "ignored-nil": nil}, + Content: []waBinary.Node{{ + Tag: "detail", + Attrs: waBinary.Attrs{}, + }}, + }} + decoded.ErrorNode.Content = []waBinary.Node{{Tag: "detail"}} + if !errors.Is(decoded, handBuilt) || !errors.Is(handBuilt, decoded) { + t.Fatal("semantically equivalent IQ error nodes did not compare equal") + } +} From 2c423abaa719453e9a93271fdd658b627877b82d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:06:28 +0300 Subject: [PATCH 033/163] fix(errors): normalize empty IQ child lists --- errors.go | 12 ++++++++---- errors_iq_test.go | 8 ++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/errors.go b/errors.go index 80bce7cad..d0e050b19 100644 --- a/errors.go +++ b/errors.go @@ -257,11 +257,15 @@ func normalizeIQErrorNode(node *waBinary.Node) *waBinary.Node { normalized.Attrs = nil } if children, ok := node.Content.([]waBinary.Node); ok { - normalizedChildren := make([]waBinary.Node, len(children)) - for index := range children { - normalizedChildren[index] = *normalizeIQErrorNode(&children[index]) + if len(children) == 0 { + normalized.Content = nil + } else { + normalizedChildren := make([]waBinary.Node, len(children)) + for index := range children { + normalizedChildren[index] = *normalizeIQErrorNode(&children[index]) + } + normalized.Content = normalizedChildren } - normalized.Content = normalizedChildren } return &normalized } diff --git a/errors_iq_test.go b/errors_iq_test.go index 1425ef8a2..98f33980b 100644 --- a/errors_iq_test.go +++ b/errors_iq_test.go @@ -30,3 +30,11 @@ func TestIQErrorIsNormalizesEquivalentAttributes(t *testing.T) { t.Fatal("semantically equivalent IQ error nodes did not compare equal") } } + +func TestIQErrorIsNormalizesEmptyChildLists(t *testing.T) { + decoded := &IQError{ErrorNode: &waBinary.Node{Tag: "error"}} + handBuilt := &IQError{ErrorNode: &waBinary.Node{Tag: "error", Content: []waBinary.Node{}}} + if !errors.Is(decoded, handBuilt) || !errors.Is(handBuilt, decoded) { + t.Fatal("empty and nil IQ error child lists did not compare equal") + } +} From 9aad620c04dd6c3302d28cf044f15a7653f3cf4a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 09:22:15 +0300 Subject: [PATCH 034/163] fix(errors): normalize encoded attribute scalars --- errors.go | 25 +++++++++++++++++++++++++ errors_iq_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/errors.go b/errors.go index d0e050b19..d9faf56ab 100644 --- a/errors.go +++ b/errors.go @@ -11,6 +11,7 @@ import ( "fmt" "net/http" "reflect" + "strconv" waBinary "go.mau.fi/whatsmeow/binary" ) @@ -246,6 +247,7 @@ func normalizeIQErrorNode(node *waBinary.Node) *waBinary.Node { if len(node.Attrs) > 0 { normalized.Attrs = make(waBinary.Attrs, len(node.Attrs)) for key, value := range node.Attrs { + value = normalizeIQErrorAttribute(value) if value != nil && value != "" { normalized.Attrs[key] = value } @@ -270,6 +272,29 @@ func normalizeIQErrorNode(node *waBinary.Node) *waBinary.Node { return &normalized } +func normalizeIQErrorAttribute(value any) any { + switch typedValue := value.(type) { + case int: + return strconv.Itoa(typedValue) + case int32: + return strconv.FormatInt(int64(typedValue), 10) + case int64: + return strconv.FormatInt(typedValue, 10) + case uint: + return strconv.FormatUint(uint64(typedValue), 10) + case uint32: + return strconv.FormatUint(uint64(typedValue), 10) + case uint64: + return strconv.FormatUint(typedValue, 10) + case bool: + return strconv.FormatBool(typedValue) + case []byte: + return string(typedValue) + default: + return value + } +} + // ElementMissingError is returned by various functions that parse XML elements when a required element is missing. type ElementMissingError struct { Tag string diff --git a/errors_iq_test.go b/errors_iq_test.go index 98f33980b..d8ed86708 100644 --- a/errors_iq_test.go +++ b/errors_iq_test.go @@ -38,3 +38,28 @@ func TestIQErrorIsNormalizesEmptyChildLists(t *testing.T) { t.Fatal("empty and nil IQ error child lists did not compare equal") } } + +func TestIQErrorIsNormalizesEncodedAttributeScalars(t *testing.T) { + for _, test := range []struct { + name string + value any + encoded string + }{ + {name: "int", value: int(-30), encoded: "-30"}, + {name: "int32", value: int32(-31), encoded: "-31"}, + {name: "int64", value: int64(-32), encoded: "-32"}, + {name: "uint", value: uint(30), encoded: "30"}, + {name: "uint32", value: uint32(31), encoded: "31"}, + {name: "uint64", value: uint64(32), encoded: "32"}, + {name: "bool", value: false, encoded: "false"}, + {name: "bytes", value: []byte("opaque"), encoded: "opaque"}, + } { + t.Run(test.name, func(t *testing.T) { + decoded := &IQError{ErrorNode: &waBinary.Node{Tag: "error", Attrs: waBinary.Attrs{"value": test.encoded}}} + handBuilt := &IQError{ErrorNode: &waBinary.Node{Tag: "error", Attrs: waBinary.Attrs{"value": test.value}}} + if !errors.Is(decoded, handBuilt) || !errors.Is(handBuilt, decoded) { + t.Fatal("wire-equivalent IQ error attributes did not compare equal") + } + }) + } +} From 0d51568477daf74d58146469171d73c0e84a0b60 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 09:26:13 +0300 Subject: [PATCH 035/163] fix: normalize and redact node scalar content --- binary/xml.go | 7 +++---- binary/xml_test.go | 10 +++++++++- errors.go | 6 ++++-- errors_iq_test.go | 26 ++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/binary/xml.go b/binary/xml.go index bdece7c8b..8bc402818 100644 --- a/binary/xml.go +++ b/binary/xml.go @@ -79,16 +79,15 @@ func printable(data []byte) string { func (n *Node) contentString() []string { split := make([]string, 0) + if n.Content != nil && sensitiveXMLNodeContent(n.Tag) { + return append(split, "[redacted]") + } switch content := n.Content.(type) { case []Node: for _, item := range content { split = append(split, strings.Split(item.String(), "\n")...) } case []byte: - if sensitiveXMLNodeContent(n.Tag) { - split = append(split, "[redacted]") - break - } if strContent := printable(content); len(strContent) > 0 { if IndentXML { split = append(split, strings.Split(string(content), "\n")...) diff --git a/binary/xml_test.go b/binary/xml_test.go index edf061e6b..e137e1ee0 100644 --- a/binary/xml_test.go +++ b/binary/xml_test.go @@ -14,6 +14,10 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { {Tag: "email", Content: []byte("owner@example.test")}, {Tag: "description", Content: []byte("Private description")}, {Tag: "website", Content: []byte("https://private.example.test")}, + {Tag: "address", Content: "34 Private Street"}, + {Tag: "email", Content: "other@example.test"}, + {Tag: "description", Content: "Other private description"}, + {Tag: "website", Content: "https://other-private.example.test"}, }, } @@ -25,12 +29,16 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { "owner@example.test", "Private description", "https://private.example.test", + "34 Private Street", + "other@example.test", + "Other private description", + "https://other-private.example.test", } { if strings.Contains(logged, sensitive) { t.Fatalf("logged sensitive value %q: %s", sensitive, logged) } } - if strings.Count(logged, "[redacted]") != 6 { + if strings.Count(logged, "[redacted]") != 10 { t.Fatalf("unexpected redacted node: %s", logged) } } diff --git a/errors.go b/errors.go index d9faf56ab..ee78e9065 100644 --- a/errors.go +++ b/errors.go @@ -247,7 +247,7 @@ func normalizeIQErrorNode(node *waBinary.Node) *waBinary.Node { if len(node.Attrs) > 0 { normalized.Attrs = make(waBinary.Attrs, len(node.Attrs)) for key, value := range node.Attrs { - value = normalizeIQErrorAttribute(value) + value = normalizeIQErrorScalar(value) if value != nil && value != "" { normalized.Attrs[key] = value } @@ -268,11 +268,13 @@ func normalizeIQErrorNode(node *waBinary.Node) *waBinary.Node { } normalized.Content = normalizedChildren } + } else { + normalized.Content = normalizeIQErrorScalar(node.Content) } return &normalized } -func normalizeIQErrorAttribute(value any) any { +func normalizeIQErrorScalar(value any) any { switch typedValue := value.(type) { case int: return strconv.Itoa(typedValue) diff --git a/errors_iq_test.go b/errors_iq_test.go index d8ed86708..48e59e1f5 100644 --- a/errors_iq_test.go +++ b/errors_iq_test.go @@ -63,3 +63,29 @@ func TestIQErrorIsNormalizesEncodedAttributeScalars(t *testing.T) { }) } } + +func TestIQErrorIsNormalizesEncodedContentScalars(t *testing.T) { + for _, test := range []struct { + name string + value any + encoded string + }{ + {name: "string", value: "details", encoded: "details"}, + {name: "int", value: int(-30), encoded: "-30"}, + {name: "int32", value: int32(-31), encoded: "-31"}, + {name: "int64", value: int64(-32), encoded: "-32"}, + {name: "uint", value: uint(30), encoded: "30"}, + {name: "uint32", value: uint32(31), encoded: "31"}, + {name: "uint64", value: uint64(32), encoded: "32"}, + {name: "bool", value: false, encoded: "false"}, + {name: "bytes", value: []byte("opaque"), encoded: "opaque"}, + } { + t.Run(test.name, func(t *testing.T) { + decoded := &IQError{ErrorNode: &waBinary.Node{Tag: "error", Content: []byte(test.encoded)}} + handBuilt := &IQError{ErrorNode: &waBinary.Node{Tag: "error", Content: test.value}} + if !errors.Is(decoded, handBuilt) || !errors.Is(handBuilt, decoded) { + t.Fatal("wire-equivalent IQ error content did not compare equal") + } + }) + } +} From 63346baf18058fd187e58b538987c45e363cc0d7 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 10:59:47 +0300 Subject: [PATCH 036/163] feat: add business catalog product mutations --- business_product_mutation.go | 704 ++++++++++++++++++++++++++++++ business_product_mutation_test.go | 254 +++++++++++ client.go | 1 + notification.go | 4 +- types/business_catalog.go | 15 + 5 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 business_product_mutation.go create mode 100644 business_product_mutation_test.go diff --git a/business_product_mutation.go b/business_product_mutation.go new file mode 100644 index 000000000..cf86895d3 --- /dev/null +++ b/business_product_mutation.go @@ -0,0 +1,704 @@ +package whatsmeow + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/socket" + "go.mau.fi/whatsmeow/types" +) + +const ( + businessGraphQLEndpoint = "https://graph.facebook.com/graphql" + businessAddProductDocumentID = "24249359867999500" + businessEditProductDocumentID = "9889773371084956" + businessDeleteProductDocumentID = "9376108569185474" + businessTokenRequestTimeout = 30 * time.Second + maxBusinessGraphQLResponseBytes = 4 * 1024 * 1024 + maxBusinessProductImageBytes = 16 * 1024 * 1024 +) + +var ( + ErrBusinessTokenRecoveryRequired = errors.New("business access token recovery is required on the primary device") + ErrBusinessTokenTooManyAttempts = errors.New("business access token request was rate limited") + errBusinessIncorrectNonce = errors.New("business access token nonce was rejected") +) + +type businessAccessToken struct { + accessToken string + actorID string +} + +type businessNonceWaiter struct { + ch chan string +} + +type businessCatalogAuthState struct { + tokenMu sync.Mutex + token businessAccessToken + nonceWaiter atomic.Pointer[businessNonceWaiter] +} + +type businessGraphQLErrorItem struct { + Code int `json:"code"` + Message string `json:"message,omitempty"` +} + +type businessGraphQLError struct { + StatusCode int + Errors []businessGraphQLErrorItem +} + +func (err *businessGraphQLError) Error() string { + if len(err.Errors) == 0 { + return fmt.Sprintf("business GraphQL request failed with status %d", err.StatusCode) + } + codes := make([]string, 0, len(err.Errors)) + for _, item := range err.Errors { + codes = append(codes, strconv.Itoa(item.Code)) + } + return "business GraphQL request failed with error code(s) " + strings.Join(codes, ",") +} + +func isBusinessGraphQLAuthError(err error) bool { + var graphErr *businessGraphQLError + if !errors.As(err, &graphErr) { + return false + } + for _, item := range graphErr.Errors { + if item.Code == 190 || item.Code == 400 { + return true + } + } + return false +} + +func validateBusinessProductInput(input types.BusinessProductInput) error { + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" { + return fmt.Errorf("business product name is empty") + } + if len(input.Name) > 256 { + return fmt.Errorf("business product name exceeds 256 bytes") + } + if len(input.Description) > 4096 { + return fmt.Errorf("business product description exceeds 4096 bytes") + } + if len(input.RetailerID) > 256 { + return fmt.Errorf("business product retailer ID exceeds 256 bytes") + } + if len(input.ImageURLs) < 1 || len(input.ImageURLs) > 10 { + return fmt.Errorf("business product must contain between 1 and 10 images") + } + for _, rawURL := range input.ImageURLs { + if err := validateBusinessMediaURL(rawURL); err != nil { + return fmt.Errorf("invalid business product image URL: %w", err) + } + } + if len(input.VideoURLs) > 10 { + return fmt.Errorf("business product cannot contain more than 10 videos") + } + for _, rawURL := range input.VideoURLs { + if err := validateBusinessMediaURL(rawURL); err != nil { + return fmt.Errorf("invalid business product video URL: %w", err) + } + } + if input.URL != "" { + parsed, err := url.ParseRequestURI(input.URL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || len(input.URL) > 2048 { + return fmt.Errorf("business product URL must be an absolute HTTPS URL of at most 2048 bytes") + } + } + if input.Price == "" { + if input.Currency != "" || input.SalePrice != "" { + return fmt.Errorf("business product currency and sale price require a price") + } + } else { + if len(input.Currency) != 3 || input.Currency != strings.ToUpper(input.Currency) { + return fmt.Errorf("business product currency must be a three-letter uppercase code") + } + if !isUnsignedDecimal(input.Price) { + return fmt.Errorf("business product price must be an integer amount in thousandths") + } + if input.SalePrice != "" && !isUnsignedDecimal(input.SalePrice) { + return fmt.Errorf("business product sale price must be an integer amount in thousandths") + } + } + if input.ComplianceCategory != "" && len(input.ComplianceCategory) > 128 { + return fmt.Errorf("business product compliance category exceeds 128 bytes") + } + if input.Compliance != nil { + if len(input.Compliance.CountryCodeOrigin) > 3 || len(input.Compliance.ImporterName) > 256 { + return fmt.Errorf("business product compliance information is invalid") + } + if address := input.Compliance.ImporterAddress; address != nil { + if len(address.Street1) > 512 || len(address.Street2) > 512 || len(address.City) > 256 || len(address.Region) > 256 || len(address.PostalCode) > 64 || len(address.CountryCode) > 3 { + return fmt.Errorf("business product importer address is invalid") + } + } + } + return nil +} + +func isUnsignedDecimal(value string) bool { + if value == "" || len(value) > 18 { + return false + } + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} + +func validateBusinessMediaURL(rawURL string) error { + if len(rawURL) > 4096 { + return fmt.Errorf("URL exceeds 4096 bytes") + } + parsed, err := url.ParseRequestURI(rawURL) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" { + return fmt.Errorf("URL must be absolute HTTPS") + } + host := strings.ToLower(parsed.Hostname()) + if host != "whatsapp.net" && !strings.HasSuffix(host, ".whatsapp.net") && host != "fbcdn.net" && !strings.HasSuffix(host, ".fbcdn.net") && host != "facebook.com" && !strings.HasSuffix(host, ".facebook.com") { + return fmt.Errorf("URL must use a WhatsApp or Meta media host") + } + return nil +} + +func buildBusinessProductInfo(input types.BusinessProductInput) map[string]any { + images := make([]map[string]any, len(input.ImageURLs)) + for index, imageURL := range input.ImageURLs { + images[index] = map[string]any{"url": imageURL} + } + media := map[string]any{"image": images} + if len(input.VideoURLs) > 0 { + videos := make([]map[string]any, len(input.VideoURLs)) + for index, videoURL := range input.VideoURLs { + videos[index] = map[string]any{"url": videoURL} + } + media["video"] = videos + } + info := map[string]any{ + "name": strings.TrimSpace(input.Name), + "media": media, + "is_hidden": input.Hidden, + } + if input.Description != "" { + info["description"] = input.Description + } + if input.URL != "" { + info["url"] = input.URL + } + if input.RetailerID != "" { + info["retailer_id"] = input.RetailerID + } + if input.Price != "" { + info["currency"] = input.Currency + info["price"] = input.Price + } + if input.SalePrice != "" { + info["sale_price"] = input.SalePrice + } + if input.Compliance != nil { + compliance := map[string]any{"country_code_origin": input.Compliance.CountryCodeOrigin} + if input.Compliance.ImporterName != "" { + compliance["importer_name"] = input.Compliance.ImporterName + } + if address := input.Compliance.ImporterAddress; address != nil { + addressInput := map[string]any{ + "country_code": address.CountryCode, + "city": address.City, + "street1": address.Street1, + } + if address.Street2 != "" { + addressInput["street2"] = address.Street2 + } + if address.Region != "" { + addressInput["region"] = address.Region + } + if address.PostalCode != "" { + addressInput["postal_code"] = address.PostalCode + } + compliance["importer_address"] = addressInput + } + info["compliance_info"] = compliance + } + if input.ComplianceCategory != "" { + info["compliance_category"] = input.ComplianceCategory + } + return info +} + +func buildBusinessProductMutationVariables(jid types.JID, productID string, input types.BusinessProductInput, width, height int) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if productID != "" { + if err := validateBusinessID("product", productID); err != nil { + return nil, err + } + } + if err := validateBusinessProductInput(input); err != nil { + return nil, err + } + width, height, err := normalizeDimensions(width, height) + if err != nil { + return nil, err + } + product := map[string]any{ + "biz_jid": jid.ToNonAD().String(), + "width": width, + "height": height, + "product_info": buildBusinessProductInfo(input), + } + if productID != "" { + product["product_id"] = productID + } + return map[string]any{"input": map[string]any{"product": product}}, nil +} + +func buildDeleteBusinessProductsVariables(jid types.JID, productIDs []string) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if len(productIDs) < 1 || len(productIDs) > 100 { + return nil, fmt.Errorf("business product delete must contain between 1 and 100 IDs") + } + seen := make(map[string]struct{}, len(productIDs)) + for _, productID := range productIDs { + if err := validateBusinessID("product", productID); err != nil { + return nil, err + } + if _, exists := seen[productID]; exists { + return nil, fmt.Errorf("duplicate product ID %q", productID) + } + seen[productID] = struct{}{} + } + return map[string]any{"input": map[string]any{ + "biz_jid": jid.ToNonAD().String(), + "product_ids": productIDs, + }}, nil +} + +func decodeBusinessProductMutation(data json.RawMessage, discriminator string) (*types.BusinessProduct, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, fmt.Errorf("decode business product mutation response: %w", err) + } + raw, ok := envelope[discriminator] + if !ok { + return nil, fmt.Errorf("business product mutation response is missing %s", discriminator) + } + var result struct { + Product *types.BusinessProduct `json:"product"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("decode %s response: %w", discriminator, err) + } + if result.Product == nil || result.Product.ID == "" { + return nil, fmt.Errorf("%s response is missing product", discriminator) + } + return result.Product, nil +} + +func decodeDeleteBusinessProducts(data json.RawMessage) (int, error) { + var envelope struct { + Result *struct { + DeletedCount *int `json:"deleted_count"` + } `json:"xfb_whatsapp_catalog_delete_product"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return 0, fmt.Errorf("decode business product delete response: %w", err) + } + if envelope.Result == nil || envelope.Result.DeletedCount == nil || *envelope.Result.DeletedCount < 0 { + return 0, fmt.Errorf("business product delete response is missing deleted_count") + } + return *envelope.Result.DeletedCount, nil +} + +func businessSilentNonceQuery() infoQuery { + return infoQuery{Namespace: "fb:thrift_iq", Type: iqGet, To: types.ServerJID, SMaxID: "118", NoRetry: true, Timeout: businessTokenRequestTimeout} +} + +func businessTokenExchangeQuery(nonce string) (infoQuery, error) { + if strings.TrimSpace(nonce) == "" || len(nonce) > 8192 { + return infoQuery{}, fmt.Errorf("business access token nonce is invalid") + } + return infoQuery{ + Namespace: "fb:thrift_iq", + Type: iqGet, + To: types.ServerJID, + SMaxID: "104", + NoRetry: true, + Timeout: businessTokenRequestTimeout, + Content: []waBinary.Node{{Tag: "parameters", Content: []waBinary.Node{{Tag: "code", Content: []byte(nonce)}}}}, + }, nil +} + +func parseBusinessTokenResponse(node *waBinary.Node) (businessAccessToken, error) { + if node == nil { + return businessAccessToken{}, fmt.Errorf("business access token response is empty") + } + accessTokenNode, ok := node.GetOptionalChildByTag("access_token") + if !ok { + return businessAccessToken{}, fmt.Errorf("business access token response is missing access_token") + } + personNode, ok := node.GetOptionalChildByTag("business_person") + if !ok { + return businessAccessToken{}, fmt.Errorf("business access token response is missing business_person") + } + accessToken, ok := accessTokenNode.Content.([]byte) + if !ok || len(accessToken) == 0 || len(accessToken) > 16384 { + return businessAccessToken{}, fmt.Errorf("business access token response contains an invalid token") + } + actorID := personNode.AttrGetter().String("id") + if actorID == "" || len(actorID) > 256 { + return businessAccessToken{}, fmt.Errorf("business access token response contains an invalid business person") + } + return businessAccessToken{accessToken: string(accessToken), actorID: actorID}, nil +} + +func (cli *Client) getBusinessCatalogAuth() *businessCatalogAuthState { + if existing := cli.businessCatalogAuth.Load(); existing != nil { + return existing + } + created := &businessCatalogAuthState{} + if cli.businessCatalogAuth.CompareAndSwap(nil, created) { + return created + } + return cli.businessCatalogAuth.Load() +} + +func (cli *Client) handleBusinessCatalogNotification(node *waBinary.Node) { + state := cli.businessCatalogAuth.Load() + if state == nil { + return + } + nonceNode, ok := node.GetOptionalChildByTag("wa_ad_account_nonce") + if !ok { + return + } + nonce, ok := nonceNode.Content.([]byte) + if !ok || len(nonce) == 0 || len(nonce) > 8192 { + return + } + waiter := state.nonceWaiter.Load() + if waiter == nil { + return + } + select { + case waiter.ch <- string(nonce): + default: + } +} + +func parseBusinessNonceRequestResponse(node *waBinary.Node) error { + result, ok := node.GetOptionalChildByTag("result") + if !ok { + return fmt.Errorf("business nonce response is missing result") + } + switch result.AttrGetter().String("status") { + case "Success": + return nil + case "RecoveryRequired": + return ErrBusinessTokenRecoveryRequired + default: + return fmt.Errorf("business nonce request returned an unknown status") + } +} + +func classifyBusinessTokenExchangeError(node *waBinary.Node, err error) error { + if node != nil { + if errorNode, ok := node.GetOptionalChildByTag("error"); ok { + switch errorNode.AttrGetter().String("code") { + case "432": + return errBusinessIncorrectNonce + case "431": + return ErrBusinessTokenTooManyAttempts + } + } + } + return err +} + +func (cli *Client) acquireBusinessAccessToken(ctx context.Context, state *businessCatalogAuthState) (businessAccessToken, error) { + waitCtx, cancel := context.WithTimeout(ctx, businessTokenRequestTimeout) + defer cancel() + waiter := &businessNonceWaiter{ch: make(chan string, 1)} + state.nonceWaiter.Store(waiter) + defer state.nonceWaiter.CompareAndSwap(waiter, nil) + + response, err := cli.sendIQ(waitCtx, businessSilentNonceQuery()) + if err != nil { + return businessAccessToken{}, fmt.Errorf("request business access token nonce: %w", err) + } + if err = parseBusinessNonceRequestResponse(response); err != nil { + return businessAccessToken{}, err + } + + var nonce string + select { + case nonce = <-waiter.ch: + case <-waitCtx.Done(): + return businessAccessToken{}, fmt.Errorf("wait for business access token nonce: %w", waitCtx.Err()) + } + exchange, err := businessTokenExchangeQuery(nonce) + if err != nil { + return businessAccessToken{}, err + } + response, err = cli.sendIQ(waitCtx, exchange) + if err != nil { + return businessAccessToken{}, classifyBusinessTokenExchangeError(response, err) + } + return parseBusinessTokenResponse(response) +} + +func (cli *Client) businessAccessToken(ctx context.Context) (businessAccessToken, error) { + state := cli.getBusinessCatalogAuth() + state.tokenMu.Lock() + defer state.tokenMu.Unlock() + if state.token.accessToken != "" { + return state.token, nil + } + var token businessAccessToken + var err error + for attempt := 0; attempt < 2; attempt++ { + token, err = cli.acquireBusinessAccessToken(ctx, state) + if !errors.Is(err, errBusinessIncorrectNonce) { + break + } + } + if err != nil { + return businessAccessToken{}, err + } + state.token = token + return token, nil +} + +func (cli *Client) invalidateBusinessAccessToken(token string) { + state := cli.businessCatalogAuth.Load() + if state == nil { + return + } + state.tokenMu.Lock() + if state.token.accessToken == token { + state.token = businessAccessToken{} + } + state.tokenMu.Unlock() +} + +func (cli *Client) sendBusinessFacebookGraphQL(ctx context.Context, endpoint, documentID, accessToken string, variables map[string]any) (json.RawMessage, error) { + if cli == nil { + return nil, ErrClientIsNil + } + if cli.mediaHTTP == nil { + return nil, fmt.Errorf("business GraphQL HTTP client is not configured") + } + body := struct { + AccessToken string `json:"access_token"` + DocumentID string `json:"doc_id"` + Variables map[string]any `json:"variables"` + Locale string `json:"locale"` + }{AccessToken: accessToken, DocumentID: documentID, Variables: variables, Locale: "en_US"} + var encoded bytes.Buffer + if err := json.NewEncoder(&encoded).Encode(body); err != nil { + return nil, fmt.Errorf("encode business GraphQL request: %w", err) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &encoded) + if err != nil { + return nil, fmt.Errorf("prepare business GraphQL request: %w", err) + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Origin", socket.Origin) + request.Header.Set("Referer", socket.Origin+"/") + if jid := cli.Store.GetJID(); !jid.IsEmpty() && jid.Device > 0 { + request.Header.Set("X-WA-Device-ID", strconv.FormatUint(uint64(jid.Device), 10)) + } + response, err := cli.mediaHTTP.Do(request) + if err != nil { + return nil, fmt.Errorf("execute business GraphQL request: %w", err) + } + defer drainAndClose(response.Body) + raw, err := io.ReadAll(io.LimitReader(response.Body, maxBusinessGraphQLResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("read business GraphQL response: %w", err) + } + if len(raw) > maxBusinessGraphQLResponseBytes { + return nil, fmt.Errorf("business GraphQL response exceeds %d bytes", maxBusinessGraphQLResponseBytes) + } + var envelope struct { + Data json.RawMessage `json:"data"` + Errors []businessGraphQLErrorItem `json:"errors"` + Error *businessGraphQLErrorItem `json:"error"` + } + if err = json.Unmarshal(raw, &envelope); err != nil { + return nil, fmt.Errorf("decode business GraphQL response: %w", err) + } + if envelope.Error != nil { + envelope.Errors = append(envelope.Errors, *envelope.Error) + } + if response.StatusCode < 200 || response.StatusCode >= 300 || len(envelope.Errors) > 0 { + return nil, &businessGraphQLError{StatusCode: response.StatusCode, Errors: envelope.Errors} + } + if len(envelope.Data) == 0 || bytes.Equal(envelope.Data, []byte("null")) { + return nil, fmt.Errorf("business GraphQL response is missing data") + } + return envelope.Data, nil +} + +func (cli *Client) executeBusinessProductMutation(ctx context.Context, documentID string, variables map[string]any) (json.RawMessage, error) { + for attempt := 0; attempt < 2; attempt++ { + token, err := cli.businessAccessToken(ctx) + if err != nil { + return nil, err + } + data, err := cli.sendBusinessFacebookGraphQL(ctx, businessGraphQLEndpoint, documentID, token.accessToken, variables) + if err == nil { + return data, nil + } + if attempt == 0 && isBusinessGraphQLAuthError(err) { + cli.invalidateBusinessAccessToken(token.accessToken) + continue + } + return nil, err + } + return nil, fmt.Errorf("business product mutation failed after token refresh") +} + +func (cli *Client) ownBusinessJID() (types.JID, error) { + if cli == nil { + return types.EmptyJID, ErrClientIsNil + } + jid := cli.Store.GetJID().ToNonAD() + if err := validateBusinessJID(jid); err != nil { + return types.EmptyJID, fmt.Errorf("business product mutation requires a paired client: %w", err) + } + return jid, nil +} + +func (cli *Client) CreateBusinessProduct(ctx context.Context, input types.BusinessProductInput, width, height int) (*types.BusinessProduct, error) { + jid, err := cli.ownBusinessJID() + if err != nil { + return nil, err + } + variables, err := buildBusinessProductMutationVariables(jid, "", input, width, height) + if err != nil { + return nil, err + } + data, err := cli.executeBusinessProductMutation(ctx, businessAddProductDocumentID, variables) + if err != nil { + return nil, fmt.Errorf("create business product: %w", err) + } + return decodeBusinessProductMutation(data, "xfb_whatsapp_catalog_add_product") +} + +func (cli *Client) UpdateBusinessProduct(ctx context.Context, productID string, input types.BusinessProductInput, width, height int) (*types.BusinessProduct, error) { + jid, err := cli.ownBusinessJID() + if err != nil { + return nil, err + } + variables, err := buildBusinessProductMutationVariables(jid, productID, input, width, height) + if err != nil { + return nil, err + } + data, err := cli.executeBusinessProductMutation(ctx, businessEditProductDocumentID, variables) + if err != nil { + return nil, fmt.Errorf("update business product: %w", err) + } + return decodeBusinessProductMutation(data, "xfb_whatsapp_catalog_edit_product") +} + +func (cli *Client) DeleteBusinessProducts(ctx context.Context, productIDs []string) (int, error) { + jid, err := cli.ownBusinessJID() + if err != nil { + return 0, err + } + variables, err := buildDeleteBusinessProductsVariables(jid, productIDs) + if err != nil { + return 0, err + } + data, err := cli.executeBusinessProductMutation(ctx, businessDeleteProductDocumentID, variables) + if err != nil { + return 0, fmt.Errorf("delete business products: %w", err) + } + return decodeDeleteBusinessProducts(data) +} + +func validateBusinessProductImage(image []byte) ([]byte, error) { + if len(image) == 0 { + return nil, fmt.Errorf("business product image is empty") + } + if len(image) > maxBusinessProductImageBytes { + return nil, fmt.Errorf("business product image exceeds %d bytes", maxBusinessProductImageBytes) + } + mimeType := http.DetectContentType(image) + if mimeType != "image/jpeg" && mimeType != "image/png" { + return nil, fmt.Errorf("business product image must be JPEG or PNG") + } + hash := sha256.Sum256(image) + return hash[:], nil +} + +func (cli *Client) UploadBusinessProductImage(ctx context.Context, image []byte) (string, error) { + hash, err := validateBusinessProductImage(image) + if err != nil { + return "", err + } + mediaConn, err := cli.refreshMediaConn(ctx, false) + if err != nil { + return "", fmt.Errorf("refresh media connection for business product image: %w", err) + } + if len(mediaConn.Hosts) == 0 { + return "", fmt.Errorf("media connection response contained no upload hosts") + } + token := base64.URLEncoding.EncodeToString(hash) + query := url.Values{"auth": {mediaConn.Auth}, "token": {token}} + uploadURL := url.URL{Scheme: "https", Host: mediaConn.Hosts[0].Hostname, Path: "/product/image/" + token, RawQuery: query.Encode()} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL.String(), bytes.NewReader(image)) + if err != nil { + return "", fmt.Errorf("prepare business product image upload: %w", err) + } + request.ContentLength = int64(len(image)) + request.Header.Set("Content-Type", "application/octet-stream") + request.Header.Set("Origin", socket.Origin) + request.Header.Set("Referer", socket.Origin+"/") + response, err := cli.mediaHTTP.Do(request) + if err != nil { + return "", fmt.Errorf("upload business product image: %w", err) + } + defer drainAndClose(response.Body) + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("business product image upload failed with status code %d", response.StatusCode) + } + var upload UploadResponse + if err = json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&upload); err != nil { + return "", fmt.Errorf("decode business product image upload response: %w", err) + } + if upload.URL != "" { + if err = validateBusinessMediaURL(upload.URL); err != nil { + return "", fmt.Errorf("business product image upload returned an invalid URL: %w", err) + } + return upload.URL, nil + } + if !strings.HasPrefix(upload.DirectPath, "/") || len(upload.DirectPath) > 4096 { + return "", fmt.Errorf("business product image upload response is missing a valid URL") + } + return "https://mmg.whatsapp.net" + upload.DirectPath, nil +} diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go new file mode 100644 index 000000000..7151b74e8 --- /dev/null +++ b/business_product_mutation_test.go @@ -0,0 +1,254 @@ +package whatsmeow + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +func syntheticProductInput() types.BusinessProductInput { + return types.BusinessProductInput{ + Name: "Mountain tea", + Description: "Synthetic loose-leaf tea", + Currency: "USD", + Price: "12500", + SalePrice: "11000", + URL: "https://shop.example.test/tea", + RetailerID: "tea-001", + ImageURLs: []string{"https://mmg.whatsapp.net/product/tea-1", "https://mmg.whatsapp.net/product/tea-2"}, + VideoURLs: []string{"https://mmg.whatsapp.net/product/tea-video"}, + Compliance: &types.BusinessComplianceInfo{ + CountryCodeOrigin: "LB", + ImporterName: "Synthetic Imports", + ImporterAddress: &types.BusinessAddress{ + Street1: "1 Test Street", City: "Beirut", CountryCode: "LB", + }, + }, + } +} + +func TestBuildBusinessProductMutationVariables(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + create, err := buildBusinessProductMutationVariables(jid, "", syntheticProductInput(), 0, 0) + if err != nil { + t.Fatal(err) + } + product := create["input"].(map[string]any)["product"].(map[string]any) + if product["biz_jid"] != jid.String() || product["width"] != 100 || product["height"] != 100 { + t.Fatalf("unexpected create envelope: %#v", product) + } + if _, ok := product["product_id"]; ok { + t.Fatal("create envelope unexpectedly contains product_id") + } + info := product["product_info"].(map[string]any) + if info["name"] != "Mountain tea" || info["price"] != "12500" || info["sale_price"] != "11000" { + t.Fatalf("unexpected product info: %#v", info) + } + media := info["media"].(map[string]any) + images := media["image"].([]map[string]any) + if len(images) != 2 || images[1]["url"] != "https://mmg.whatsapp.net/product/tea-2" { + t.Fatalf("unexpected image input: %#v", images) + } + + edit, err := buildBusinessProductMutationVariables(jid, "product-100", syntheticProductInput(), 320, 240) + if err != nil { + t.Fatal(err) + } + edited := edit["input"].(map[string]any)["product"].(map[string]any) + if edited["product_id"] != "product-100" || edited["width"] != 320 || edited["height"] != 240 { + t.Fatalf("unexpected edit envelope: %#v", edited) + } +} + +func TestBuildBusinessProductMutationVariablesRejectsUnsafeInput(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + tests := []types.BusinessProductInput{ + {}, + {Name: "Tea"}, + {Name: "Tea", ImageURLs: []string{"http://mmg.whatsapp.net/product/tea"}}, + {Name: "Tea", ImageURLs: []string{"https://example.test/product/tea"}}, + {Name: "Tea", ImageURLs: []string{"https://mmg.whatsapp.net/product/tea"}, Currency: "US", Price: "1250"}, + {Name: "Tea", ImageURLs: []string{"https://mmg.whatsapp.net/product/tea"}, Currency: "USD", Price: "12.50"}, + {Name: strings.Repeat("n", 257), ImageURLs: []string{"https://mmg.whatsapp.net/product/tea"}}, + } + for i, input := range tests { + if _, err := buildBusinessProductMutationVariables(jid, "", input, 100, 100); err == nil { + t.Fatalf("case %d unexpectedly passed", i) + } + } + if _, err := buildBusinessProductMutationVariables(jid, strings.Repeat("p", 257), syntheticProductInput(), 100, 100); err == nil { + t.Fatal("oversized product ID unexpectedly passed") + } +} + +func TestBuildDeleteBusinessProductsVariables(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + variables, err := buildDeleteBusinessProductsVariables(jid, []string{"product-100", "product-101"}) + if err != nil { + t.Fatal(err) + } + input := variables["input"].(map[string]any) + if input["biz_jid"] != jid.String() || len(input["product_ids"].([]string)) != 2 { + t.Fatalf("unexpected delete variables: %#v", variables) + } + for _, ids := range [][]string{nil, {"same", "same"}, {strings.Repeat("p", 257)}} { + if _, err = buildDeleteBusinessProductsVariables(jid, ids); err == nil { + t.Fatalf("invalid IDs unexpectedly passed: %#v", ids) + } + } +} + +func TestDecodeBusinessProductMutationResponses(t *testing.T) { + productJSON := `{"id":"product-100","name":"Mountain tea","price":"12500","currency":"USD","media":{"images":[]},"status_info":{"status":"APPROVED"}}` + created, err := decodeBusinessProductMutation(json.RawMessage(`{"xfb_whatsapp_catalog_add_product":{"product":`+productJSON+`}}`), "xfb_whatsapp_catalog_add_product") + if err != nil || created.ID != "product-100" { + t.Fatalf("created = %#v, error = %v", created, err) + } + updated, err := decodeBusinessProductMutation(json.RawMessage(`{"xfb_whatsapp_catalog_edit_product":{"product":`+productJSON+`}}`), "xfb_whatsapp_catalog_edit_product") + if err != nil || updated.Name != "Mountain tea" { + t.Fatalf("updated = %#v, error = %v", updated, err) + } + deleted, err := decodeDeleteBusinessProducts(json.RawMessage(`{"xfb_whatsapp_catalog_delete_product":{"deleted_count":2}}`)) + if err != nil || deleted != 2 { + t.Fatalf("deleted = %d, error = %v", deleted, err) + } + if _, err = decodeBusinessProductMutation(json.RawMessage(`{"unexpected":{}}`), "xfb_whatsapp_catalog_add_product"); err == nil { + t.Fatal("missing product discriminator unexpectedly passed") + } +} + +func TestBusinessCatalogAuthNodesAndResponse(t *testing.T) { + nonceQuery := businessSilentNonceQuery() + if nonceQuery.Namespace != "fb:thrift_iq" || nonceQuery.SMaxID != "118" || nonceQuery.Type != iqGet || nonceQuery.To != types.ServerJID { + t.Fatalf("unexpected nonce query: %#v", nonceQuery) + } + exchange, err := businessTokenExchangeQuery("synthetic-nonce") + if err != nil { + t.Fatal(err) + } + parameters := exchange.Content.([]waBinary.Node)[0] + code := parameters.Content.([]waBinary.Node)[0] + if exchange.SMaxID != "104" || code.Tag != "code" || string(code.Content.([]byte)) != "synthetic-nonce" { + t.Fatalf("unexpected exchange query: %#v", exchange) + } + response := waBinary.Node{Tag: "iq", Attrs: waBinary.Attrs{"type": "result"}, Content: []waBinary.Node{ + {Tag: "access_token", Content: []byte("synthetic-token")}, + {Tag: "session_cookies", Content: []byte("ignored")}, + {Tag: "business_person", Attrs: waBinary.Attrs{"id": "person-100"}}, + {Tag: "token_type", Content: []byte("Strong")}, + }} + token, err := parseBusinessTokenResponse(&response) + if err != nil || token.accessToken != "synthetic-token" || token.actorID != "person-100" { + t.Fatalf("token = %#v, error = %v", token, err) + } + if _, err = businessTokenExchangeQuery(""); err == nil { + t.Fatal("empty nonce unexpectedly passed") + } +} + +func TestHandleBusinessNonceNotificationIsLazyAndNonBlocking(t *testing.T) { + client := &Client{} + node := &waBinary.Node{Tag: "notification", Attrs: waBinary.Attrs{"type": "business"}, Content: []waBinary.Node{{Tag: "wa_ad_account_nonce", Content: []byte("unused")}}} + client.handleBusinessCatalogNotification(node) + if client.businessCatalogAuth.Load() != nil { + t.Fatal("unsolicited nonce allocated catalog auth state") + } + state := client.getBusinessCatalogAuth() + waiter := &businessNonceWaiter{ch: make(chan string, 1)} + state.nonceWaiter.Store(waiter) + client.handleBusinessCatalogNotification(node) + select { + case nonce := <-waiter.ch: + if nonce != "unused" { + t.Fatalf("nonce = %q", nonce) + } + default: + t.Fatal("nonce was not delivered") + } +} + +func TestSendBusinessFacebookGraphQL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("unexpected request: %s %#v", r.Method, r.Header) + } + var body struct { + AccessToken string `json:"access_token"` + DocumentID string `json:"doc_id"` + Locale string `json:"locale"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.AccessToken != "synthetic-token" || body.DocumentID != businessAddProductDocumentID || body.Locale != "en_US" || body.Variables["input"] == nil { + t.Fatalf("unexpected GraphQL body: %#v", body) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"data":{"xfb_whatsapp_catalog_add_product":{"product":{"id":"product-100"}}}}`) + })) + defer server.Close() + client := &Client{mediaHTTP: server.Client()} + data, err := client.sendBusinessFacebookGraphQL(context.Background(), server.URL, businessAddProductDocumentID, "synthetic-token", map[string]any{"input": map[string]any{"product": map[string]any{"name": "Tea"}}}) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte("product-100")) { + t.Fatalf("unexpected data: %s", data) + } +} + +func TestSendBusinessFacebookGraphQLClassifiesAuthErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"errors":[{"code":190,"message":"expired"}]}`) + })) + defer server.Close() + client := &Client{mediaHTTP: server.Client()} + _, err := client.sendBusinessFacebookGraphQL(context.Background(), server.URL, businessAddProductDocumentID, "synthetic-token", map[string]any{"input": map[string]any{}}) + if err == nil || !isBusinessGraphQLAuthError(err) || strings.Contains(err.Error(), "synthetic-token") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUploadBusinessProductImageUsesPlaintextProductPath(t *testing.T) { + image := append([]byte("\x89PNG\r\n\x1a\n"), []byte("synthetic-product-image")...) + hash := sha256.Sum256(image) + token := base64.URLEncoding.EncodeToString(hash[:]) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/product/image/"+token || r.URL.Query().Get("auth") != "synthetic-auth" { + t.Fatalf("unexpected upload URL: %s", r.URL.RequestURI()) + } + body, err := io.ReadAll(r.Body) + if err != nil || !bytes.Equal(body, image) { + t.Fatalf("body mismatch: %v", err) + } + _, _ = io.WriteString(w, `{"direct_path":"/product/tea"}`) + })) + defer server.Close() + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := &Client{ + mediaHTTP: server.Client(), + mediaConnCache: &MediaConn{Auth: "synthetic-auth", TTL: 3600, FetchedAt: time.Now(), Hosts: []MediaConnHost{{Hostname: serverURL.Host}}}, + } + got, err := client.UploadBusinessProductImage(context.Background(), image) + if err != nil || got != "https://mmg.whatsapp.net/product/tea" { + t.Fatalf("URL = %q, error = %v", got, err) + } +} diff --git a/client.go b/client.go index 542c55be3..b2f9a6195 100644 --- a/client.go +++ b/client.go @@ -153,6 +153,7 @@ type Client struct { responseWaiters map[string]chan<- *waBinary.Node responseWaitersLock sync.Mutex + businessCatalogAuth atomic.Pointer[businessCatalogAuthState] handlerQueue chan *waBinary.Node eventHandlers []wrappedEventHandler diff --git a/notification.go b/notification.go index bfbefefc5..4c7ce77dc 100644 --- a/notification.go +++ b/notification.go @@ -506,9 +506,11 @@ func (cli *Client) handleNotification(ctx context.Context, node *waBinary.Node) cli.handleStatusNotification(ctx, node) case "passkey_prologue_request": cli.handlePasskeyNotification(ctx, node) + case "business": + cli.handleBusinessCatalogNotification(node) case "crsc_continuation": go cli.tryHandlePasskeyContinuationNotification(ctx, node) - // Other types: business, disappearing_mode, server, status, pay, psa + // Other types: disappearing_mode, server, status, pay, psa default: cli.Log.Debugf("Unhandled notification with type %s", notifType) } diff --git a/types/business_catalog.go b/types/business_catalog.go index df68dc30f..a413756f1 100644 --- a/types/business_catalog.go +++ b/types/business_catalog.go @@ -28,6 +28,21 @@ type BusinessProduct struct { VariantInfo *BusinessProductVariant `json:"variant_info,omitempty"` } +type BusinessProductInput struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Currency string `json:"currency,omitempty"` + Price string `json:"price,omitempty"` + SalePrice string `json:"sale_price,omitempty"` + URL string `json:"url,omitempty"` + RetailerID string `json:"retailer_id,omitempty"` + Hidden bool `json:"is_hidden"` + ImageURLs []string `json:"image_urls"` + VideoURLs []string `json:"video_urls,omitempty"` + ComplianceCategory string `json:"compliance_category,omitempty"` + Compliance *BusinessComplianceInfo `json:"compliance_info,omitempty"` +} + type BusinessComplianceInfo struct { CountryCodeOrigin string `json:"country_code_origin,omitempty"` ImporterName string `json:"importer_name,omitempty"` From 85ee37f277b9a07b0a5282d366dedd47a9ed85c9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:36:29 +0300 Subject: [PATCH 037/163] fix: deliver business nonces out of band --- business_product_mutation_test.go | 22 ++++++++++++++++++++++ client.go | 7 +++++++ 2 files changed, 29 insertions(+) diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index 7151b74e8..c111541ab 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -180,6 +180,28 @@ func TestHandleBusinessNonceNotificationIsLazyAndNonBlocking(t *testing.T) { } } +func TestBusinessNonceDeliveredBeforeHandlerQueue(t *testing.T) { + client := &Client{handlerQueue: make(chan *waBinary.Node, 1)} + client.handlerQueue <- &waBinary.Node{Tag: "message"} + state := client.getBusinessCatalogAuth() + waiter := &businessNonceWaiter{ch: make(chan string, 1)} + state.nonceWaiter.Store(waiter) + node := &waBinary.Node{Tag: "notification", Attrs: waBinary.Attrs{"type": "business"}, Content: []waBinary.Node{{Tag: "wa_ad_account_nonce", Content: []byte("synthetic-nonce")}}} + + client.handleOutOfBandNode(node) + select { + case nonce := <-waiter.ch: + if nonce != "synthetic-nonce" { + t.Fatalf("nonce = %q", nonce) + } + default: + t.Fatal("nonce was blocked behind the handler queue") + } + if len(client.handlerQueue) != 1 { + t.Fatalf("out-of-band delivery changed handler queue length to %d", len(client.handlerQueue)) + } +} + func TestSendBusinessFacebookGraphQL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { diff --git a/client.go b/client.go index b2f9a6195..bdee60385 100644 --- a/client.go +++ b/client.go @@ -903,6 +903,7 @@ func (cli *Client) handleFrame(ctx context.Context, data []byte) { } } cli.recvLog.Debugf("%s", node) + cli.handleOutOfBandNode(node) // Signal-disabled handoff: parse and dispatch UndecryptedMessage // synchronously from the recv goroutine, so the event interleaves // with [RawNodeHandler] callbacks in wire order. Going through the @@ -935,6 +936,12 @@ func (cli *Client) handleFrame(ctx context.Context, data []byte) { } } +func (cli *Client) handleOutOfBandNode(node *waBinary.Node) { + if node.Tag == "notification" && node.Attrs["type"] == "business" { + cli.handleBusinessCatalogNotification(node) + } +} + func (cli *Client) enqueueNode(ctx context.Context, node *waBinary.Node) { select { case cli.handlerQueue <- node: From 7ce670069d76808fdd180bd58ca50e87a2298467 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:20:44 +0300 Subject: [PATCH 038/163] fix: suppress queued nonce redelivery --- business_product_mutation.go | 8 ++++++++ business_product_mutation_test.go | 19 +++++++++++++++++++ client.go | 1 + notification.go | 2 +- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/business_product_mutation.go b/business_product_mutation.go index cf86895d3..b2dde3080 100644 --- a/business_product_mutation.go +++ b/business_product_mutation.go @@ -38,6 +38,8 @@ var ( errBusinessIncorrectNonce = errors.New("business access token nonce was rejected") ) +const businessNonceDeliveredAttr = "__whatsmeow_business_nonce_delivered" + type businessAccessToken struct { accessToken string actorID string @@ -408,6 +410,12 @@ func (cli *Client) handleBusinessCatalogNotification(node *waBinary.Node) { } } +func (cli *Client) handleQueuedBusinessCatalogNotification(node *waBinary.Node) { + if delivered, _ := node.Attrs[businessNonceDeliveredAttr].(bool); !delivered { + cli.handleBusinessCatalogNotification(node) + } +} + func parseBusinessNonceRequestResponse(node *waBinary.Node) error { result, ok := node.GetOptionalChildByTag("result") if !ok { diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index c111541ab..1dc9f539e 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -202,6 +202,25 @@ func TestBusinessNonceDeliveredBeforeHandlerQueue(t *testing.T) { } } +func TestBusinessNonceIsNotRedeliveredFromHandlerQueue(t *testing.T) { + client := &Client{} + state := client.getBusinessCatalogAuth() + firstWaiter := &businessNonceWaiter{ch: make(chan string, 1)} + state.nonceWaiter.Store(firstWaiter) + node := &waBinary.Node{Tag: "notification", Attrs: waBinary.Attrs{"type": "business"}, Content: []waBinary.Node{{Tag: "wa_ad_account_nonce", Content: []byte("stale-nonce")}}} + client.handleOutOfBandNode(node) + <-firstWaiter.ch + + secondWaiter := &businessNonceWaiter{ch: make(chan string, 1)} + state.nonceWaiter.Store(secondWaiter) + client.handleQueuedBusinessCatalogNotification(node) + select { + case nonce := <-secondWaiter.ch: + t.Fatalf("queued handler redelivered stale nonce %q", nonce) + default: + } +} + func TestSendBusinessFacebookGraphQL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { diff --git a/client.go b/client.go index bdee60385..dac3ecc9b 100644 --- a/client.go +++ b/client.go @@ -939,6 +939,7 @@ func (cli *Client) handleFrame(ctx context.Context, data []byte) { func (cli *Client) handleOutOfBandNode(node *waBinary.Node) { if node.Tag == "notification" && node.Attrs["type"] == "business" { cli.handleBusinessCatalogNotification(node) + node.Attrs[businessNonceDeliveredAttr] = true } } diff --git a/notification.go b/notification.go index 4c7ce77dc..435a2b7be 100644 --- a/notification.go +++ b/notification.go @@ -507,7 +507,7 @@ func (cli *Client) handleNotification(ctx context.Context, node *waBinary.Node) case "passkey_prologue_request": cli.handlePasskeyNotification(ctx, node) case "business": - cli.handleBusinessCatalogNotification(node) + cli.handleQueuedBusinessCatalogNotification(node) case "crsc_continuation": go cli.tryHandlePasskeyContinuationNotification(ctx, node) // Other types: disappearing_mode, server, status, pay, psa From f04491da7e31c1a7e37dab3d33dc34ef045a90f3 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:36:05 +0300 Subject: [PATCH 039/163] fix: redact product upload transport errors --- business_product_mutation.go | 3 +++ business_product_mutation_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/business_product_mutation.go b/business_product_mutation.go index b2dde3080..ae41d4d28 100644 --- a/business_product_mutation.go +++ b/business_product_mutation.go @@ -689,6 +689,9 @@ func (cli *Client) UploadBusinessProductImage(ctx context.Context, image []byte) request.Header.Set("Referer", socket.Origin+"/") response, err := cli.mediaHTTP.Do(request) if err != nil { + if urlErr, ok := err.(*url.Error); ok { + err = urlErr.Err + } return "", fmt.Errorf("upload business product image: %w", err) } defer drainAndClose(response.Body) diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index 1dc9f539e..31382e109 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -293,3 +294,29 @@ func TestUploadBusinessProductImageUsesPlaintextProductPath(t *testing.T) { t.Fatalf("URL = %q, error = %v", got, err) } } + +type businessProductRoundTripFunc func(*http.Request) (*http.Response, error) + +func (roundTrip businessProductRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) +} + +func TestUploadBusinessProductImageRedactsTransportURL(t *testing.T) { + sentinel := errors.New("synthetic transport failure") + client := &Client{ + mediaHTTP: &http.Client{Transport: businessProductRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, sentinel + })}, + mediaConnCache: &MediaConn{ + Auth: "sensitive-auth", TTL: 3600, FetchedAt: time.Now(), Hosts: []MediaConnHost{{Hostname: "upload.invalid"}}, + }, + } + image := append([]byte("\x89PNG\r\n\x1a\n"), []byte("synthetic-product-image")...) + _, err := client.UploadBusinessProductImage(context.Background(), image) + if !errors.Is(err, sentinel) { + t.Fatalf("transport cause was not preserved: %v", err) + } + if strings.Contains(err.Error(), "sensitive-auth") { + t.Fatalf("transport error exposed auth query: %v", err) + } +} From 492333904a4ab388b7b169f4f21b52b7733e17f1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:53:36 +0300 Subject: [PATCH 040/163] fix: harden product upload validation --- business_product_mutation.go | 17 ++++++++++++++++- business_product_mutation_test.go | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/business_product_mutation.go b/business_product_mutation.go index ae41d4d28..f68cbe234 100644 --- a/business_product_mutation.go +++ b/business_product_mutation.go @@ -130,7 +130,7 @@ func validateBusinessProductInput(input types.BusinessProductInput) error { return fmt.Errorf("business product currency and sale price require a price") } } else { - if len(input.Currency) != 3 || input.Currency != strings.ToUpper(input.Currency) { + if !isUppercaseCurrency(input.Currency) { return fmt.Errorf("business product currency must be a three-letter uppercase code") } if !isUnsignedDecimal(input.Price) { @@ -168,6 +168,18 @@ func isUnsignedDecimal(value string) bool { return true } +func isUppercaseCurrency(value string) bool { + if len(value) != 3 { + return false + } + for _, char := range value { + if char < 'A' || char > 'Z' { + return false + } + } + return true +} + func validateBusinessMediaURL(rawURL string) error { if len(rawURL) > 4096 { return fmt.Errorf("URL exceeds 4096 bytes") @@ -681,6 +693,9 @@ func (cli *Client) UploadBusinessProductImage(ctx context.Context, image []byte) uploadURL := url.URL{Scheme: "https", Host: mediaConn.Hosts[0].Hostname, Path: "/product/image/" + token, RawQuery: query.Encode()} request, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL.String(), bytes.NewReader(image)) if err != nil { + if urlErr, ok := err.(*url.Error); ok { + err = urlErr.Err + } return "", fmt.Errorf("prepare business product image upload: %w", err) } request.ContentLength = int64(len(image)) diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index 31382e109..d2a42f2c5 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -82,6 +82,7 @@ func TestBuildBusinessProductMutationVariablesRejectsUnsafeInput(t *testing.T) { {Name: "Tea", ImageURLs: []string{"http://mmg.whatsapp.net/product/tea"}}, {Name: "Tea", ImageURLs: []string{"https://example.test/product/tea"}}, {Name: "Tea", ImageURLs: []string{"https://mmg.whatsapp.net/product/tea"}, Currency: "US", Price: "1250"}, + {Name: "Tea", ImageURLs: []string{"https://mmg.whatsapp.net/product/tea"}, Currency: "123", Price: "1250"}, {Name: "Tea", ImageURLs: []string{"https://mmg.whatsapp.net/product/tea"}, Currency: "USD", Price: "12.50"}, {Name: strings.Repeat("n", 257), ImageURLs: []string{"https://mmg.whatsapp.net/product/tea"}}, } @@ -320,3 +321,19 @@ func TestUploadBusinessProductImageRedactsTransportURL(t *testing.T) { t.Fatalf("transport error exposed auth query: %v", err) } } + +func TestUploadBusinessProductImageRedactsRequestConstructionURL(t *testing.T) { + client := &Client{ + mediaConnCache: &MediaConn{ + Auth: "sensitive-auth", TTL: 3600, FetchedAt: time.Now(), Hosts: []MediaConnHost{{Hostname: "[invalid"}}, + }, + } + image := append([]byte("\x89PNG\r\n\x1a\n"), []byte("synthetic-product-image")...) + _, err := client.UploadBusinessProductImage(context.Background(), image) + if err == nil { + t.Fatal("malformed upload host unexpectedly passed") + } + if strings.Contains(err.Error(), "sensitive-auth") { + t.Fatalf("request construction error exposed auth query: %v", err) + } +} From bdec5aa236c6aaa1bfd4fc66909043c24cbc24b9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:01:24 +0300 Subject: [PATCH 041/163] fix: make business token waits cancelable --- business_product_mutation.go | 18 +++++++++++------- business_product_mutation_test.go | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/business_product_mutation.go b/business_product_mutation.go index f68cbe234..86cfb5a94 100644 --- a/business_product_mutation.go +++ b/business_product_mutation.go @@ -13,7 +13,6 @@ import ( "net/url" "strconv" "strings" - "sync" "sync/atomic" "time" @@ -50,7 +49,7 @@ type businessNonceWaiter struct { } type businessCatalogAuthState struct { - tokenMu sync.Mutex + tokenLock chan struct{} token businessAccessToken nonceWaiter atomic.Pointer[businessNonceWaiter] } @@ -392,7 +391,8 @@ func (cli *Client) getBusinessCatalogAuth() *businessCatalogAuthState { if existing := cli.businessCatalogAuth.Load(); existing != nil { return existing } - created := &businessCatalogAuthState{} + created := &businessCatalogAuthState{tokenLock: make(chan struct{}, 1)} + created.tokenLock <- struct{}{} if cli.businessCatalogAuth.CompareAndSwap(nil, created) { return created } @@ -491,8 +491,12 @@ func (cli *Client) acquireBusinessAccessToken(ctx context.Context, state *busine func (cli *Client) businessAccessToken(ctx context.Context) (businessAccessToken, error) { state := cli.getBusinessCatalogAuth() - state.tokenMu.Lock() - defer state.tokenMu.Unlock() + select { + case <-state.tokenLock: + defer func() { state.tokenLock <- struct{}{} }() + case <-ctx.Done(): + return businessAccessToken{}, ctx.Err() + } if state.token.accessToken != "" { return state.token, nil } @@ -516,11 +520,11 @@ func (cli *Client) invalidateBusinessAccessToken(token string) { if state == nil { return } - state.tokenMu.Lock() + <-state.tokenLock if state.token.accessToken == token { state.token = businessAccessToken{} } - state.tokenMu.Unlock() + state.tokenLock <- struct{}{} } func (cli *Client) sendBusinessFacebookGraphQL(ctx context.Context, endpoint, documentID, accessToken string, variables map[string]any) (json.RawMessage, error) { diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index d2a42f2c5..d8667b604 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -223,6 +223,28 @@ func TestBusinessNonceIsNotRedeliveredFromHandlerQueue(t *testing.T) { } } +func TestBusinessAccessTokenLockObservesCancellation(t *testing.T) { + client := &Client{} + state := client.getBusinessCatalogAuth() + <-state.tokenLock + defer func() { state.tokenLock <- struct{}{} }() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan error, 1) + go func() { + _, err := client.businessAccessToken(ctx) + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled token waiter remained blocked") + } +} + func TestSendBusinessFacebookGraphQL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { From c9a38c586a3bdb947d99c778901939a49ffd7183 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:14:53 +0300 Subject: [PATCH 042/163] fix: make business token invalidation cancelable --- business_product_mutation.go | 15 +++++++++++---- business_product_mutation_test.go | 12 ++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/business_product_mutation.go b/business_product_mutation.go index 86cfb5a94..6801242a4 100644 --- a/business_product_mutation.go +++ b/business_product_mutation.go @@ -515,16 +515,21 @@ func (cli *Client) businessAccessToken(ctx context.Context) (businessAccessToken return token, nil } -func (cli *Client) invalidateBusinessAccessToken(token string) { +func (cli *Client) invalidateBusinessAccessToken(ctx context.Context, token string) error { state := cli.businessCatalogAuth.Load() if state == nil { - return + return nil + } + select { + case <-state.tokenLock: + case <-ctx.Done(): + return ctx.Err() } - <-state.tokenLock if state.token.accessToken == token { state.token = businessAccessToken{} } state.tokenLock <- struct{}{} + return nil } func (cli *Client) sendBusinessFacebookGraphQL(ctx context.Context, endpoint, documentID, accessToken string, variables map[string]any) (json.RawMessage, error) { @@ -598,7 +603,9 @@ func (cli *Client) executeBusinessProductMutation(ctx context.Context, documentI return data, nil } if attempt == 0 && isBusinessGraphQLAuthError(err) { - cli.invalidateBusinessAccessToken(token.accessToken) + if err = cli.invalidateBusinessAccessToken(ctx, token.accessToken); err != nil { + return nil, err + } continue } return nil, err diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index d8667b604..2936929e9 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -245,6 +245,18 @@ func TestBusinessAccessTokenLockObservesCancellation(t *testing.T) { } } +func TestBusinessAccessTokenInvalidationObservesCancellation(t *testing.T) { + client := &Client{} + state := client.getBusinessCatalogAuth() + <-state.tokenLock + defer func() { state.tokenLock <- struct{}{} }() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := client.invalidateBusinessAccessToken(ctx, "synthetic-token"); !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context canceled", err) + } +} + func TestSendBusinessFacebookGraphQL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { From 13cf06cd8b6c26bce1465e2600e6374870f74bd6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:40:30 +0300 Subject: [PATCH 043/163] fix: send business mutation actor IDs --- business_product_mutation.go | 27 +++++++++++++++- business_product_mutation_test.go | 51 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/business_product_mutation.go b/business_product_mutation.go index 6801242a4..ceac7d558 100644 --- a/business_product_mutation.go +++ b/business_product_mutation.go @@ -592,13 +592,38 @@ func (cli *Client) sendBusinessFacebookGraphQL(ctx context.Context, endpoint, do return envelope.Data, nil } +func businessProductMutationVariablesWithActor(variables map[string]any, actorID string) (map[string]any, error) { + if strings.TrimSpace(actorID) == "" { + return nil, fmt.Errorf("business product mutation actor ID is empty") + } + input, ok := variables["input"].(map[string]any) + if !ok { + return nil, fmt.Errorf("business product mutation variables are missing input") + } + result := make(map[string]any, len(variables)) + for key, value := range variables { + result[key] = value + } + actorInput := make(map[string]any, len(input)+1) + for key, value := range input { + actorInput[key] = value + } + actorInput["actor_id"] = actorID + result["input"] = actorInput + return result, nil +} + func (cli *Client) executeBusinessProductMutation(ctx context.Context, documentID string, variables map[string]any) (json.RawMessage, error) { for attempt := 0; attempt < 2; attempt++ { token, err := cli.businessAccessToken(ctx) if err != nil { return nil, err } - data, err := cli.sendBusinessFacebookGraphQL(ctx, businessGraphQLEndpoint, documentID, token.accessToken, variables) + requestVariables, err := businessProductMutationVariablesWithActor(variables, token.actorID) + if err != nil { + return nil, err + } + data, err := cli.sendBusinessFacebookGraphQL(ctx, businessGraphQLEndpoint, documentID, token.accessToken, requestVariables) if err == nil { return data, nil } diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index 2936929e9..6091962c7 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" "strings" "testing" "time" @@ -257,6 +258,56 @@ func TestBusinessAccessTokenInvalidationObservesCancellation(t *testing.T) { } } +func TestExecuteBusinessProductMutationUsesCurrentActorID(t *testing.T) { + client := &Client{} + state := client.getBusinessCatalogAuth() + <-state.tokenLock + state.token = businessAccessToken{accessToken: "old-token", actorID: "actor-old"} + state.tokenLock <- struct{}{} + + var actors []string + var tokens []string + requests := 0 + client.mediaHTTP = &http.Client{Transport: businessProductRoundTripFunc(func(request *http.Request) (*http.Response, error) { + requests++ + var body struct { + AccessToken string `json:"access_token"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + input := body.Variables["input"].(map[string]any) + actors = append(actors, input["actor_id"].(string)) + tokens = append(tokens, body.AccessToken) + if requests == 1 { + <-state.tokenLock + state.token = businessAccessToken{accessToken: "new-token", actorID: "actor-new"} + state.tokenLock <- struct{}{} + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"errors":[{"code":190}]}`)), + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":{"ok":true}}`)), + }, nil + })} + variables := map[string]any{"input": map[string]any{"product": map[string]any{"name": "Tea"}}} + if _, err := client.executeBusinessProductMutation(context.Background(), businessAddProductDocumentID, variables); err != nil { + t.Fatal(err) + } + if !slices.Equal(actors, []string{"actor-old", "actor-new"}) || !slices.Equal(tokens, []string{"old-token", "new-token"}) { + t.Fatalf("actors = %v, tokens = %v", actors, tokens) + } + if _, exists := variables["input"].(map[string]any)["actor_id"]; exists { + t.Fatal("mutation variables were modified in place") + } +} + func TestSendBusinessFacebookGraphQL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { From f3285ec322ca88eee265099d0f9b92c5276dd16e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:07:34 +0300 Subject: [PATCH 044/163] fix(business): classify HTTP auth failures before JSON --- business_product_mutation.go | 17 ++++++++++++----- business_product_mutation_test.go | 12 ++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/business_product_mutation.go b/business_product_mutation.go index ceac7d558..9779c239a 100644 --- a/business_product_mutation.go +++ b/business_product_mutation.go @@ -80,6 +80,9 @@ func isBusinessGraphQLAuthError(err error) bool { if !errors.As(err, &graphErr) { return false } + if graphErr.StatusCode == http.StatusUnauthorized || graphErr.StatusCode == http.StatusForbidden { + return true + } for _, item := range graphErr.Errors { if item.Code == 190 || item.Code == 400 { return true @@ -577,13 +580,17 @@ func (cli *Client) sendBusinessFacebookGraphQL(ctx context.Context, endpoint, do Errors []businessGraphQLErrorItem `json:"errors"` Error *businessGraphQLErrorItem `json:"error"` } - if err = json.Unmarshal(raw, &envelope); err != nil { - return nil, fmt.Errorf("decode business GraphQL response: %w", err) - } - if envelope.Error != nil { + err = json.Unmarshal(raw, &envelope) + if err == nil && envelope.Error != nil { envelope.Errors = append(envelope.Errors, *envelope.Error) } - if response.StatusCode < 200 || response.StatusCode >= 300 || len(envelope.Errors) > 0 { + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, &businessGraphQLError{StatusCode: response.StatusCode, Errors: envelope.Errors} + } + if err != nil { + return nil, fmt.Errorf("decode business GraphQL response: %w", err) + } + if len(envelope.Errors) > 0 { return nil, &businessGraphQLError{StatusCode: response.StatusCode, Errors: envelope.Errors} } if len(envelope.Data) == 0 || bytes.Equal(envelope.Data, []byte("null")) { diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index 6091962c7..0a94cae1d 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -352,6 +352,18 @@ func TestSendBusinessFacebookGraphQLClassifiesAuthErrors(t *testing.T) { } } +func TestSendBusinessFacebookGraphQLClassifiesHTTPAuthErrorsWithoutJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + client := &Client{mediaHTTP: server.Client()} + _, err := client.sendBusinessFacebookGraphQL(context.Background(), server.URL, businessAddProductDocumentID, "synthetic-token", map[string]any{"input": map[string]any{}}) + if err == nil || !isBusinessGraphQLAuthError(err) || strings.Contains(err.Error(), "decode") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestUploadBusinessProductImageUsesPlaintextProductPath(t *testing.T) { image := append([]byte("\x89PNG\r\n\x1a\n"), []byte("synthetic-product-image")...) hash := sha256.Sum256(image) From 50944ddd31a1f9ac8a24d4ee8477a31ab32ca2be Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 09:33:45 +0300 Subject: [PATCH 045/163] fix: redact business access tokens --- binary/xml.go | 2 +- binary/xml_test.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/binary/xml.go b/binary/xml.go index 8bc402818..992952085 100644 --- a/binary/xml.go +++ b/binary/xml.go @@ -27,7 +27,7 @@ func sensitiveXMLAttribute(key string) bool { func sensitiveXMLNodeContent(tag string) bool { switch tag { - case "address", "description", "email", "website": + case "access_token", "address", "description", "email", "website": return true default: return false diff --git a/binary/xml_test.go b/binary/xml_test.go index e137e1ee0..7340db0f4 100644 --- a/binary/xml_test.go +++ b/binary/xml_test.go @@ -10,6 +10,7 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { Tag: "business_profile", Attrs: Attrs{"auth": "media-secret", "token": "upload-secret"}, Content: []Node{ + {Tag: "access_token", Content: []byte("catalog-token")}, {Tag: "address", Content: []byte("12 Private Street")}, {Tag: "email", Content: []byte("owner@example.test")}, {Tag: "description", Content: []byte("Private description")}, @@ -18,6 +19,7 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { {Tag: "email", Content: "other@example.test"}, {Tag: "description", Content: "Other private description"}, {Tag: "website", Content: "https://other-private.example.test"}, + {Tag: "access_token", Content: "other-catalog-token"}, }, } @@ -25,6 +27,7 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { for _, sensitive := range []string{ "media-secret", "upload-secret", + "catalog-token", "12 Private Street", "owner@example.test", "Private description", @@ -33,12 +36,13 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { "other@example.test", "Other private description", "https://other-private.example.test", + "other-catalog-token", } { if strings.Contains(logged, sensitive) { t.Fatalf("logged sensitive value %q: %s", sensitive, logged) } } - if strings.Count(logged, "[redacted]") != 10 { + if strings.Count(logged, "[redacted]") != 12 { t.Fatalf("unexpected redacted node: %s", logged) } } From 48facd9ab5a7a2dccccbe8a2a4cf7cfb071ff19d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 09:39:26 +0300 Subject: [PATCH 046/163] fix: redact business session cookies --- binary/xml.go | 2 +- binary/xml_test.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/binary/xml.go b/binary/xml.go index 992952085..4afd52b98 100644 --- a/binary/xml.go +++ b/binary/xml.go @@ -27,7 +27,7 @@ func sensitiveXMLAttribute(key string) bool { func sensitiveXMLNodeContent(tag string) bool { switch tag { - case "access_token", "address", "description", "email", "website": + case "access_token", "address", "description", "email", "session_cookies", "website": return true default: return false diff --git a/binary/xml_test.go b/binary/xml_test.go index 7340db0f4..b0c97554c 100644 --- a/binary/xml_test.go +++ b/binary/xml_test.go @@ -11,6 +11,7 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { Attrs: Attrs{"auth": "media-secret", "token": "upload-secret"}, Content: []Node{ {Tag: "access_token", Content: []byte("catalog-token")}, + {Tag: "session_cookies", Content: []byte("catalog-cookie")}, {Tag: "address", Content: []byte("12 Private Street")}, {Tag: "email", Content: []byte("owner@example.test")}, {Tag: "description", Content: []byte("Private description")}, @@ -20,6 +21,7 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { {Tag: "description", Content: "Other private description"}, {Tag: "website", Content: "https://other-private.example.test"}, {Tag: "access_token", Content: "other-catalog-token"}, + {Tag: "session_cookies", Content: "other-catalog-cookie"}, }, } @@ -28,6 +30,7 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { "media-secret", "upload-secret", "catalog-token", + "catalog-cookie", "12 Private Street", "owner@example.test", "Private description", @@ -37,12 +40,13 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { "Other private description", "https://other-private.example.test", "other-catalog-token", + "other-catalog-cookie", } { if strings.Contains(logged, sensitive) { t.Fatalf("logged sensitive value %q: %s", sensitive, logged) } } - if strings.Count(logged, "[redacted]") != 12 { + if strings.Count(logged, "[redacted]") != 14 { t.Fatalf("unexpected redacted node: %s", logged) } } From b76eab8a943d4a28ac925e790ff5d0bb0c5cb63a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 09:47:00 +0300 Subject: [PATCH 047/163] fix: redact business token nonces --- binary/xml.go | 2 +- binary/xml_test.go | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/binary/xml.go b/binary/xml.go index 4afd52b98..3870d909f 100644 --- a/binary/xml.go +++ b/binary/xml.go @@ -27,7 +27,7 @@ func sensitiveXMLAttribute(key string) bool { func sensitiveXMLNodeContent(tag string) bool { switch tag { - case "access_token", "address", "description", "email", "session_cookies", "website": + case "access_token", "address", "code", "description", "email", "session_cookies", "wa_ad_account_nonce", "website": return true default: return false diff --git a/binary/xml_test.go b/binary/xml_test.go index b0c97554c..3bb5e3721 100644 --- a/binary/xml_test.go +++ b/binary/xml_test.go @@ -12,6 +12,8 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { Content: []Node{ {Tag: "access_token", Content: []byte("catalog-token")}, {Tag: "session_cookies", Content: []byte("catalog-cookie")}, + {Tag: "wa_ad_account_nonce", Content: []byte("catalog-nonce")}, + {Tag: "code", Content: []byte("exchange-code")}, {Tag: "address", Content: []byte("12 Private Street")}, {Tag: "email", Content: []byte("owner@example.test")}, {Tag: "description", Content: []byte("Private description")}, @@ -22,6 +24,8 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { {Tag: "website", Content: "https://other-private.example.test"}, {Tag: "access_token", Content: "other-catalog-token"}, {Tag: "session_cookies", Content: "other-catalog-cookie"}, + {Tag: "wa_ad_account_nonce", Content: "other-catalog-nonce"}, + {Tag: "code", Content: "other-exchange-code"}, }, } @@ -31,6 +35,8 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { "upload-secret", "catalog-token", "catalog-cookie", + "catalog-nonce", + "exchange-code", "12 Private Street", "owner@example.test", "Private description", @@ -41,12 +47,14 @@ func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { "https://other-private.example.test", "other-catalog-token", "other-catalog-cookie", + "other-catalog-nonce", + "other-exchange-code", } { if strings.Contains(logged, sensitive) { t.Fatalf("logged sensitive value %q: %s", sensitive, logged) } } - if strings.Count(logged, "[redacted]") != 14 { + if strings.Count(logged, "[redacted]") != 18 { t.Fatalf("unexpected redacted node: %s", logged) } } From a507131687e861ed64277a546175c00ad395a65b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 09:49:40 +0300 Subject: [PATCH 048/163] test: cover business credential redaction --- binary/xml.go | 2 +- binary/xml_test.go | 83 ++++++++++++++++++++-------------------------- 2 files changed, 37 insertions(+), 48 deletions(-) diff --git a/binary/xml.go b/binary/xml.go index 3870d909f..76bd83a7f 100644 --- a/binary/xml.go +++ b/binary/xml.go @@ -27,7 +27,7 @@ func sensitiveXMLAttribute(key string) bool { func sensitiveXMLNodeContent(tag string) bool { switch tag { - case "access_token", "address", "code", "description", "email", "session_cookies", "wa_ad_account_nonce", "website": + case "access_token", "address", "code", "description", "email", "session_cookies", "token", "wa_ad_account_nonce", "website": return true default: return false diff --git a/binary/xml_test.go b/binary/xml_test.go index 3bb5e3721..59f3f482a 100644 --- a/binary/xml_test.go +++ b/binary/xml_test.go @@ -5,56 +5,45 @@ import ( "testing" ) -func TestNodeStringRedactsBusinessProfileAndCredentials(t *testing.T) { - node := Node{ - Tag: "business_profile", - Attrs: Attrs{"auth": "media-secret", "token": "upload-secret"}, - Content: []Node{ - {Tag: "access_token", Content: []byte("catalog-token")}, - {Tag: "session_cookies", Content: []byte("catalog-cookie")}, - {Tag: "wa_ad_account_nonce", Content: []byte("catalog-nonce")}, - {Tag: "code", Content: []byte("exchange-code")}, - {Tag: "address", Content: []byte("12 Private Street")}, - {Tag: "email", Content: []byte("owner@example.test")}, - {Tag: "description", Content: []byte("Private description")}, - {Tag: "website", Content: []byte("https://private.example.test")}, - {Tag: "address", Content: "34 Private Street"}, - {Tag: "email", Content: "other@example.test"}, - {Tag: "description", Content: "Other private description"}, - {Tag: "website", Content: "https://other-private.example.test"}, - {Tag: "access_token", Content: "other-catalog-token"}, - {Tag: "session_cookies", Content: "other-catalog-cookie"}, - {Tag: "wa_ad_account_nonce", Content: "other-catalog-nonce"}, - {Tag: "code", Content: "other-exchange-code"}, - }, - } - - logged := node.String() - for _, sensitive := range []string{ - "media-secret", - "upload-secret", - "catalog-token", - "catalog-cookie", - "catalog-nonce", - "exchange-code", - "12 Private Street", - "owner@example.test", - "Private description", - "https://private.example.test", - "34 Private Street", - "other@example.test", - "Other private description", - "https://other-private.example.test", - "other-catalog-token", - "other-catalog-cookie", - "other-catalog-nonce", - "other-exchange-code", +func TestNodeStringRedactsSensitiveContent(t *testing.T) { + for _, tag := range []string{ + "access_token", + "address", + "code", + "description", + "email", + "session_cookies", + "token", + "wa_ad_account_nonce", + "website", } { - if strings.Contains(logged, sensitive) { - t.Fatalf("logged sensitive value %q: %s", sensitive, logged) + for _, contentType := range []struct { + name string + value func(string) any + }{ + {name: "bytes", value: func(secret string) any { return []byte(secret) }}, + {name: "string", value: func(secret string) any { return secret }}, + } { + t.Run(tag+"/"+contentType.name, func(t *testing.T) { + secret := "private-" + tag + logged := (Node{Tag: tag, Content: contentType.value(secret)}).String() + if strings.Contains(logged, secret) || !strings.Contains(logged, "[redacted]") { + t.Fatalf("sensitive content was not redacted: %s", logged) + } + }) } } - if strings.Count(logged, "[redacted]") != 18 { +} + +func TestNodeStringRedactsSensitiveAttributes(t *testing.T) { + logged := (Node{ + Tag: "cover_photo", + Attrs: Attrs{"auth": "media-secret", "token": "upload-secret", "id": "cover-100"}, + }).String() + if strings.Contains(logged, "media-secret") || strings.Contains(logged, "upload-secret") { + t.Fatalf("sensitive attributes were not redacted: %s", logged) + } + if !strings.Contains(logged, `id="cover-100"`) || strings.Count(logged, "[redacted]") != 2 { t.Fatalf("unexpected redacted node: %s", logged) } } From 7f85e4bd4fba5e54780b46031b9f4ad0e9936e55 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 11:51:41 +0300 Subject: [PATCH 049/163] feat(business): add collection mutations --- business_collection_mutation.go | 280 +++++++++++++++++++++++++++ business_collection_mutation_test.go | 106 ++++++++++ business_product_mutation.go | 18 +- business_product_mutation_test.go | 2 +- types/business_catalog.go | 17 ++ 5 files changed, 413 insertions(+), 10 deletions(-) create mode 100644 business_collection_mutation.go create mode 100644 business_collection_mutation_test.go diff --git a/business_collection_mutation.go b/business_collection_mutation.go new file mode 100644 index 000000000..7456ff30f --- /dev/null +++ b/business_collection_mutation.go @@ -0,0 +1,280 @@ +package whatsmeow + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/google/uuid" + + "go.mau.fi/whatsmeow/types" +) + +const ( + businessCreateCollectionDocumentID = "29361942130088470" + businessDeleteCollectionsDocumentID = "29970196299234260" + businessUpdateCollectionDocumentID = "24486970300891371" + businessReorderCollectionsDocumentID = "9930298893688430" + maxBusinessCollectionItems = 100 + maxBusinessCollectionMoves = 100 +) + +func validateBusinessCollectionName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("business collection name is empty") + } + if len(name) > 256 { + return "", fmt.Errorf("business collection name exceeds 256 bytes") + } + return name, nil +} + +func validateBusinessCollectionProductIDs(productIDs []string, allowEmpty bool) error { + if (!allowEmpty && len(productIDs) == 0) || len(productIDs) > maxBusinessCollectionItems { + return fmt.Errorf("business collection product list must contain between 1 and %d IDs", maxBusinessCollectionItems) + } + seen := make(map[string]struct{}, len(productIDs)) + for _, productID := range productIDs { + if err := validateBusinessID("product", productID); err != nil { + return err + } + if _, exists := seen[productID]; exists { + return fmt.Errorf("duplicate product ID %q", productID) + } + seen[productID] = struct{}{} + } + return nil +} + +func buildCreateBusinessCollectionVariables(jid types.JID, name string, productIDs []string, catalogSessionID string) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + name, err := validateBusinessCollectionName(name) + if err != nil { + return nil, err + } + if err = validateBusinessCollectionProductIDs(productIDs, false); err != nil { + return nil, err + } + if err = validateBusinessID("catalog session", catalogSessionID); err != nil { + return nil, err + } + return map[string]any{"input": map[string]any{"collection": map[string]any{ + "name": name, "product_ids": productIDs, "biz_jid": jid.ToNonAD().String(), "catalog_session_id": catalogSessionID, + }}}, nil +} + +func buildUpdateBusinessCollectionVariables(jid types.JID, collectionID string, update types.BusinessCollectionUpdate, catalogSessionID string) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if err := validateBusinessID("collection", collectionID); err != nil { + return nil, err + } + if err := validateBusinessID("catalog session", catalogSessionID); err != nil { + return nil, err + } + if err := validateBusinessCollectionProductIDs(update.AddProductIDs, true); err != nil { + return nil, err + } + if err := validateBusinessCollectionProductIDs(update.RemoveProductIDs, true); err != nil { + return nil, err + } + if update.Name == nil && len(update.AddProductIDs) == 0 && len(update.RemoveProductIDs) == 0 { + return nil, fmt.Errorf("business collection update is empty") + } + removed := make(map[string]struct{}, len(update.RemoveProductIDs)) + for _, productID := range update.RemoveProductIDs { + removed[productID] = struct{}{} + } + for _, productID := range update.AddProductIDs { + if _, exists := removed[productID]; exists { + return nil, fmt.Errorf("product ID %q cannot be added and removed together", productID) + } + } + collection := map[string]any{ + "id": collectionID, "biz_jid": jid.ToNonAD().String(), "catalog_session_id": catalogSessionID, + } + if update.Name != nil { + name, err := validateBusinessCollectionName(*update.Name) + if err != nil { + return nil, err + } + collection["name"] = name + } + if len(update.AddProductIDs) > 0 { + collection["add"] = map[string]any{"ids": update.AddProductIDs} + } + if len(update.RemoveProductIDs) > 0 { + collection["remove"] = map[string]any{"ids": update.RemoveProductIDs} + } + return map[string]any{"input": map[string]any{"collection": collection}}, nil +} + +func buildDeleteBusinessCollectionsVariables(jid types.JID, collectionIDs []string, catalogSessionID string) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if len(collectionIDs) < 1 || len(collectionIDs) > maxBusinessCollectionItems { + return nil, fmt.Errorf("business collection delete must contain between 1 and %d IDs", maxBusinessCollectionItems) + } + if err := validateBusinessID("catalog session", catalogSessionID); err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(collectionIDs)) + for _, collectionID := range collectionIDs { + if err := validateBusinessID("collection", collectionID); err != nil { + return nil, err + } + if _, exists := seen[collectionID]; exists { + return nil, fmt.Errorf("duplicate collection ID %q", collectionID) + } + seen[collectionID] = struct{}{} + } + return map[string]any{"input": map[string]any{"collections": map[string]any{ + "collection_ids": collectionIDs, "biz_jid": jid.ToNonAD().String(), "catalog_session_id": catalogSessionID, + }}}, nil +} + +func buildReorderBusinessCollectionsVariables(jid types.JID, moves []types.BusinessCollectionMove) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if len(moves) < 1 || len(moves) > maxBusinessCollectionMoves { + return nil, fmt.Errorf("business collection reorder must contain between 1 and %d moves", maxBusinessCollectionMoves) + } + items := make([]map[string]any, len(moves)) + seen := make(map[string]struct{}, len(moves)) + for index, move := range moves { + if err := validateBusinessID("collection", move.CollectionID); err != nil { + return nil, err + } + if move.FromIndex < 0 || move.ToIndex < 0 || move.FromIndex >= maxBusinessCollectionMoves || move.ToIndex >= maxBusinessCollectionMoves { + return nil, fmt.Errorf("business collection move index must be between 0 and %d", maxBusinessCollectionMoves-1) + } + if _, exists := seen[move.CollectionID]; exists { + return nil, fmt.Errorf("duplicate collection move %q", move.CollectionID) + } + seen[move.CollectionID] = struct{}{} + items[index] = map[string]any{"collection_id": move.CollectionID, "from_index": move.FromIndex, "to_index": move.ToIndex} + } + return map[string]any{"input": map[string]any{"biz_jid": jid.ToNonAD().String(), "move": items}}, nil +} + +func decodeBusinessCollectionMutation(data json.RawMessage, discriminator string) (*types.BusinessCollectionMutationResult, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, fmt.Errorf("decode business collection mutation response: %w", err) + } + raw, ok := envelope[discriminator] + if !ok { + return nil, fmt.Errorf("business collection mutation response is missing %s", discriminator) + } + var response struct { + Collection *struct { + ID string `json:"id"` + Status *struct { + Status string `json:"status"` + } `json:"status_info"` + } `json:"collection"` + } + if err := json.Unmarshal(raw, &response); err != nil { + return nil, fmt.Errorf("decode %s response: %w", discriminator, err) + } + if response.Collection == nil || response.Collection.ID == "" || response.Collection.Status == nil || response.Collection.Status.Status == "" { + return nil, fmt.Errorf("%s response is missing collection status", discriminator) + } + return &types.BusinessCollectionMutationResult{ID: response.Collection.ID, ReviewStatus: response.Collection.Status.Status}, nil +} + +func decodeBusinessCollectionSuccess(data json.RawMessage, discriminator string) error { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("decode business collection response: %w", err) + } + raw, ok := envelope[discriminator] + if !ok { + return fmt.Errorf("business collection response is missing %s", discriminator) + } + var response struct { + Success *bool `json:"success"` + } + if err := json.Unmarshal(raw, &response); err != nil { + return fmt.Errorf("decode %s response: %w", discriminator, err) + } + if response.Success == nil || !*response.Success { + return fmt.Errorf("%s response did not confirm success", discriminator) + } + return nil +} + +func newBusinessCatalogSessionID() string { + return uuid.NewString() +} + +func (cli *Client) CreateBusinessCollection(ctx context.Context, name string, productIDs []string) (*types.BusinessCollectionMutationResult, error) { + jid, err := cli.ownBusinessJID() + if err != nil { + return nil, err + } + variables, err := buildCreateBusinessCollectionVariables(jid, name, productIDs, newBusinessCatalogSessionID()) + if err != nil { + return nil, err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessCreateCollectionDocumentID, variables) + if err != nil { + return nil, fmt.Errorf("create business collection: %w", err) + } + return decodeBusinessCollectionMutation(data, "xfb_whatsapp_catalog_create_collection") +} + +func (cli *Client) UpdateBusinessCollection(ctx context.Context, collectionID string, update types.BusinessCollectionUpdate) (*types.BusinessCollectionMutationResult, error) { + jid, err := cli.ownBusinessJID() + if err != nil { + return nil, err + } + variables, err := buildUpdateBusinessCollectionVariables(jid, collectionID, update, newBusinessCatalogSessionID()) + if err != nil { + return nil, err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessUpdateCollectionDocumentID, variables) + if err != nil { + return nil, fmt.Errorf("update business collection: %w", err) + } + return decodeBusinessCollectionMutation(data, "xfb_whatsapp_catalog_update_collection") +} + +func (cli *Client) DeleteBusinessCollections(ctx context.Context, collectionIDs []string) error { + jid, err := cli.ownBusinessJID() + if err != nil { + return err + } + variables, err := buildDeleteBusinessCollectionsVariables(jid, collectionIDs, newBusinessCatalogSessionID()) + if err != nil { + return err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessDeleteCollectionsDocumentID, variables) + if err != nil { + return fmt.Errorf("delete business collections: %w", err) + } + return decodeBusinessCollectionSuccess(data, "xfb_whatsapp_catalog_delete_collections") +} + +func (cli *Client) ReorderBusinessCollections(ctx context.Context, moves []types.BusinessCollectionMove) error { + jid, err := cli.ownBusinessJID() + if err != nil { + return err + } + variables, err := buildReorderBusinessCollectionsVariables(jid, moves) + if err != nil { + return err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessReorderCollectionsDocumentID, variables) + if err != nil { + return fmt.Errorf("reorder business collections: %w", err) + } + return decodeBusinessCollectionSuccess(data, "xfb_whatsapp_catalog_update_collection_list") +} diff --git a/business_collection_mutation_test.go b/business_collection_mutation_test.go new file mode 100644 index 000000000..ecc74c1ca --- /dev/null +++ b/business_collection_mutation_test.go @@ -0,0 +1,106 @@ +package whatsmeow + +import ( + "encoding/json" + "strings" + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestBuildCreateBusinessCollectionVariables(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + variables, err := buildCreateBusinessCollectionVariables(jid, " Summer tea ", []string{"product-1", "product-2"}, "session-1") + if err != nil { + t.Fatal(err) + } + collection := variables["input"].(map[string]any)["collection"].(map[string]any) + if collection["name"] != "Summer tea" || collection["biz_jid"] != jid.String() || collection["catalog_session_id"] != "session-1" { + t.Fatalf("unexpected collection: %#v", collection) + } + if len(collection["product_ids"].([]string)) != 2 { + t.Fatalf("unexpected product IDs: %#v", collection) + } + for _, test := range []struct { + name string + productIDs []string + }{ + {"", []string{"product-1"}}, + {strings.Repeat("n", 257), []string{"product-1"}}, + {"Tea", nil}, + {"Tea", []string{"same", "same"}}, + } { + if _, err = buildCreateBusinessCollectionVariables(jid, test.name, test.productIDs, "session-1"); err == nil { + t.Fatalf("invalid create unexpectedly passed: %#v", test) + } + } +} + +func TestBuildUpdateBusinessCollectionVariables(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + name := "Tea gifts" + variables, err := buildUpdateBusinessCollectionVariables(jid, "collection-1", types.BusinessCollectionUpdate{ + Name: &name, AddProductIDs: []string{"product-3"}, RemoveProductIDs: []string{"product-1"}, + }, "session-1") + if err != nil { + t.Fatal(err) + } + collection := variables["input"].(map[string]any)["collection"].(map[string]any) + if collection["id"] != "collection-1" || collection["name"] != "Tea gifts" { + t.Fatalf("unexpected update: %#v", collection) + } + if collection["add"].(map[string]any)["ids"].([]string)[0] != "product-3" || collection["remove"].(map[string]any)["ids"].([]string)[0] != "product-1" { + t.Fatalf("unexpected membership update: %#v", collection) + } + for _, update := range []types.BusinessCollectionUpdate{ + {}, + {AddProductIDs: []string{"same"}, RemoveProductIDs: []string{"same"}}, + {AddProductIDs: []string{"same", "same"}}, + } { + if _, err = buildUpdateBusinessCollectionVariables(jid, "collection-1", update, "session-1"); err == nil { + t.Fatalf("invalid update unexpectedly passed: %#v", update) + } + } +} + +func TestBuildDeleteAndReorderBusinessCollectionsVariables(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + deleted, err := buildDeleteBusinessCollectionsVariables(jid, []string{"collection-1", "collection-2"}, "session-1") + if err != nil || deleted["input"].(map[string]any)["collections"] == nil { + t.Fatalf("delete = %#v, error = %v", deleted, err) + } + moves := []types.BusinessCollectionMove{{CollectionID: "collection-2", FromIndex: 1, ToIndex: 0}} + reordered, err := buildReorderBusinessCollectionsVariables(jid, moves) + if err != nil { + t.Fatal(err) + } + move := reordered["input"].(map[string]any)["move"].([]map[string]any)[0] + if move["collection_id"] != "collection-2" || move["from_index"] != 1 || move["to_index"] != 0 { + t.Fatalf("unexpected move: %#v", move) + } + if _, err = buildDeleteBusinessCollectionsVariables(jid, []string{"same", "same"}, "session-1"); err == nil { + t.Fatal("duplicate delete unexpectedly passed") + } + if _, err = buildReorderBusinessCollectionsVariables(jid, []types.BusinessCollectionMove{{CollectionID: "collection-1", FromIndex: -1, ToIndex: 0}}); err == nil { + t.Fatal("negative move unexpectedly passed") + } +} + +func TestDecodeBusinessCollectionMutationResponses(t *testing.T) { + created, err := decodeBusinessCollectionMutation(json.RawMessage(`{"xfb_whatsapp_catalog_create_collection":{"collection":{"id":"collection-1","status_info":{"status":"PENDING"}}}}`), "xfb_whatsapp_catalog_create_collection") + if err != nil || created.ID != "collection-1" || created.ReviewStatus != "PENDING" { + t.Fatalf("created = %#v, error = %v", created, err) + } + updated, err := decodeBusinessCollectionMutation(json.RawMessage(`{"xfb_whatsapp_catalog_update_collection":{"collection":{"id":"collection-1","status_info":{"status":"APPROVED"}}}}`), "xfb_whatsapp_catalog_update_collection") + if err != nil || updated.ReviewStatus != "APPROVED" { + t.Fatalf("updated = %#v, error = %v", updated, err) + } + for _, discriminator := range []string{"xfb_whatsapp_catalog_delete_collections", "xfb_whatsapp_catalog_update_collection_list"} { + if err = decodeBusinessCollectionSuccess(json.RawMessage(`{"`+discriminator+`":{"success":true}}`), discriminator); err != nil { + t.Fatalf("%s success failed: %v", discriminator, err) + } + if err = decodeBusinessCollectionSuccess(json.RawMessage(`{"`+discriminator+`":{"success":false}}`), discriminator); err == nil { + t.Fatalf("%s false success unexpectedly passed", discriminator) + } + } +} diff --git a/business_product_mutation.go b/business_product_mutation.go index 9779c239a..513c80a99 100644 --- a/business_product_mutation.go +++ b/business_product_mutation.go @@ -599,13 +599,13 @@ func (cli *Client) sendBusinessFacebookGraphQL(ctx context.Context, endpoint, do return envelope.Data, nil } -func businessProductMutationVariablesWithActor(variables map[string]any, actorID string) (map[string]any, error) { +func businessCatalogMutationVariablesWithActor(variables map[string]any, actorID string) (map[string]any, error) { if strings.TrimSpace(actorID) == "" { - return nil, fmt.Errorf("business product mutation actor ID is empty") + return nil, fmt.Errorf("business catalog mutation actor ID is empty") } input, ok := variables["input"].(map[string]any) if !ok { - return nil, fmt.Errorf("business product mutation variables are missing input") + return nil, fmt.Errorf("business catalog mutation variables are missing input") } result := make(map[string]any, len(variables)) for key, value := range variables { @@ -620,13 +620,13 @@ func businessProductMutationVariablesWithActor(variables map[string]any, actorID return result, nil } -func (cli *Client) executeBusinessProductMutation(ctx context.Context, documentID string, variables map[string]any) (json.RawMessage, error) { +func (cli *Client) executeBusinessCatalogMutation(ctx context.Context, documentID string, variables map[string]any) (json.RawMessage, error) { for attempt := 0; attempt < 2; attempt++ { token, err := cli.businessAccessToken(ctx) if err != nil { return nil, err } - requestVariables, err := businessProductMutationVariablesWithActor(variables, token.actorID) + requestVariables, err := businessCatalogMutationVariablesWithActor(variables, token.actorID) if err != nil { return nil, err } @@ -642,7 +642,7 @@ func (cli *Client) executeBusinessProductMutation(ctx context.Context, documentI } return nil, err } - return nil, fmt.Errorf("business product mutation failed after token refresh") + return nil, fmt.Errorf("business catalog mutation failed after token refresh") } func (cli *Client) ownBusinessJID() (types.JID, error) { @@ -665,7 +665,7 @@ func (cli *Client) CreateBusinessProduct(ctx context.Context, input types.Busine if err != nil { return nil, err } - data, err := cli.executeBusinessProductMutation(ctx, businessAddProductDocumentID, variables) + data, err := cli.executeBusinessCatalogMutation(ctx, businessAddProductDocumentID, variables) if err != nil { return nil, fmt.Errorf("create business product: %w", err) } @@ -681,7 +681,7 @@ func (cli *Client) UpdateBusinessProduct(ctx context.Context, productID string, if err != nil { return nil, err } - data, err := cli.executeBusinessProductMutation(ctx, businessEditProductDocumentID, variables) + data, err := cli.executeBusinessCatalogMutation(ctx, businessEditProductDocumentID, variables) if err != nil { return nil, fmt.Errorf("update business product: %w", err) } @@ -697,7 +697,7 @@ func (cli *Client) DeleteBusinessProducts(ctx context.Context, productIDs []stri if err != nil { return 0, err } - data, err := cli.executeBusinessProductMutation(ctx, businessDeleteProductDocumentID, variables) + data, err := cli.executeBusinessCatalogMutation(ctx, businessDeleteProductDocumentID, variables) if err != nil { return 0, fmt.Errorf("delete business products: %w", err) } diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index 0a94cae1d..3856bcc0d 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -297,7 +297,7 @@ func TestExecuteBusinessProductMutationUsesCurrentActorID(t *testing.T) { }, nil })} variables := map[string]any{"input": map[string]any{"product": map[string]any{"name": "Tea"}}} - if _, err := client.executeBusinessProductMutation(context.Background(), businessAddProductDocumentID, variables); err != nil { + if _, err := client.executeBusinessCatalogMutation(context.Background(), businessAddProductDocumentID, variables); err != nil { t.Fatal(err) } if !slices.Equal(actors, []string{"actor-old", "actor-new"}) || !slices.Equal(tokens, []string{"old-token", "new-token"}) { diff --git a/types/business_catalog.go b/types/business_catalog.go index a413756f1..daa474dc5 100644 --- a/types/business_catalog.go +++ b/types/business_catalog.go @@ -156,3 +156,20 @@ type BusinessCollectionStatus struct { CommerceURL string `json:"commerce_url,omitempty"` RejectReason string `json:"reject_reason,omitempty"` } + +type BusinessCollectionUpdate struct { + Name *string `json:"name,omitempty"` + AddProductIDs []string `json:"add_product_ids,omitempty"` + RemoveProductIDs []string `json:"remove_product_ids,omitempty"` +} + +type BusinessCollectionMutationResult struct { + ID string `json:"id"` + ReviewStatus string `json:"review_status"` +} + +type BusinessCollectionMove struct { + CollectionID string `json:"collection_id"` + FromIndex int `json:"from_index"` + ToIndex int `json:"to_index"` +} From 3fc15fa7a25b4d7400bb2bd8322e88f50f3769fc Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 12:21:56 +0300 Subject: [PATCH 050/163] Add business commerce controls --- business_collection_mutation.go | 10 +- business_collection_mutation_test.go | 4 +- business_commerce_control.go | 192 +++++++++++++++++++++++++++ business_commerce_control_test.go | 84 ++++++++++++ 4 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 business_commerce_control.go create mode 100644 business_commerce_control_test.go diff --git a/business_collection_mutation.go b/business_collection_mutation.go index 7456ff30f..aedea24b0 100644 --- a/business_collection_mutation.go +++ b/business_collection_mutation.go @@ -190,14 +190,14 @@ func decodeBusinessCollectionMutation(data json.RawMessage, discriminator string return &types.BusinessCollectionMutationResult{ID: response.Collection.ID, ReviewStatus: response.Collection.Status.Status}, nil } -func decodeBusinessCollectionSuccess(data json.RawMessage, discriminator string) error { +func decodeBusinessCatalogSuccess(data json.RawMessage, discriminator string) error { var envelope map[string]json.RawMessage if err := json.Unmarshal(data, &envelope); err != nil { - return fmt.Errorf("decode business collection response: %w", err) + return fmt.Errorf("decode business catalog response: %w", err) } raw, ok := envelope[discriminator] if !ok { - return fmt.Errorf("business collection response is missing %s", discriminator) + return fmt.Errorf("business catalog response is missing %s", discriminator) } var response struct { Success *bool `json:"success"` @@ -260,7 +260,7 @@ func (cli *Client) DeleteBusinessCollections(ctx context.Context, collectionIDs if err != nil { return fmt.Errorf("delete business collections: %w", err) } - return decodeBusinessCollectionSuccess(data, "xfb_whatsapp_catalog_delete_collections") + return decodeBusinessCatalogSuccess(data, "xfb_whatsapp_catalog_delete_collections") } func (cli *Client) ReorderBusinessCollections(ctx context.Context, moves []types.BusinessCollectionMove) error { @@ -276,5 +276,5 @@ func (cli *Client) ReorderBusinessCollections(ctx context.Context, moves []types if err != nil { return fmt.Errorf("reorder business collections: %w", err) } - return decodeBusinessCollectionSuccess(data, "xfb_whatsapp_catalog_update_collection_list") + return decodeBusinessCatalogSuccess(data, "xfb_whatsapp_catalog_update_collection_list") } diff --git a/business_collection_mutation_test.go b/business_collection_mutation_test.go index ecc74c1ca..6408eb159 100644 --- a/business_collection_mutation_test.go +++ b/business_collection_mutation_test.go @@ -96,10 +96,10 @@ func TestDecodeBusinessCollectionMutationResponses(t *testing.T) { t.Fatalf("updated = %#v, error = %v", updated, err) } for _, discriminator := range []string{"xfb_whatsapp_catalog_delete_collections", "xfb_whatsapp_catalog_update_collection_list"} { - if err = decodeBusinessCollectionSuccess(json.RawMessage(`{"`+discriminator+`":{"success":true}}`), discriminator); err != nil { + if err = decodeBusinessCatalogSuccess(json.RawMessage(`{"`+discriminator+`":{"success":true}}`), discriminator); err != nil { t.Fatalf("%s success failed: %v", discriminator, err) } - if err = decodeBusinessCollectionSuccess(json.RawMessage(`{"`+discriminator+`":{"success":false}}`), discriminator); err == nil { + if err = decodeBusinessCatalogSuccess(json.RawMessage(`{"`+discriminator+`":{"success":false}}`), discriminator); err == nil { t.Fatalf("%s false success unexpectedly passed", discriminator) } } diff --git a/business_commerce_control.go b/business_commerce_control.go new file mode 100644 index 000000000..e899094dc --- /dev/null +++ b/business_commerce_control.go @@ -0,0 +1,192 @@ +package whatsmeow + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "go.mau.fi/whatsmeow/types" +) + +const ( + businessCreateCatalogDocumentID = "29232780583035464" + businessUpdateCommerceDocumentID = "9797519763673469" + businessProductVisibilityDocumentID = "9665162096898581" + businessAppealProductDocumentID = "29276343172013990" + businessAppealCollectionDocumentID = "9971242039605207" + maxBusinessCatalogAppealReasonBytes = 4096 +) + +func buildCreateBusinessCatalogVariables(jid types.JID) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + return map[string]any{"input": map[string]any{ + "product_catalog": map[string]any{"biz_jid": jid.ToNonAD().String()}, + "platform": "WEB", + }}, nil +} + +func buildBusinessCartVariables(jid types.JID, enabled bool) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + return map[string]any{"input": map[string]any{ + "biz_jid": jid.ToNonAD().String(), "cart_enabled": enabled, + }}, nil +} + +func buildBusinessProductVisibilityVariables(jid types.JID, productID string, hidden bool) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if err := validateBusinessID("product", productID); err != nil { + return nil, err + } + return map[string]any{"input": map[string]any{ + "jid": jid.ToNonAD().String(), + "products": []map[string]any{{"product_id": productID, "is_hidden": hidden}}, + }}, nil +} + +func validateBusinessCatalogAppealReason(reason string) (string, error) { + reason = strings.TrimSpace(reason) + if reason == "" { + return "", fmt.Errorf("business catalog appeal reason is empty") + } + if len(reason) > maxBusinessCatalogAppealReasonBytes { + return "", fmt.Errorf("business catalog appeal reason exceeds %d bytes", maxBusinessCatalogAppealReasonBytes) + } + return reason, nil +} + +func buildBusinessProductAppealVariables(jid types.JID, productID, reason string) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if err := validateBusinessID("product", productID); err != nil { + return nil, err + } + reason, err := validateBusinessCatalogAppealReason(reason) + if err != nil { + return nil, err + } + return map[string]any{"input": map[string]any{ + "jid": jid.ToNonAD().String(), "product_id": productID, "reason": reason, + }}, nil +} + +func buildBusinessCollectionAppealVariables(jid types.JID, collectionID, reason string) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + if err := validateBusinessID("collection", collectionID); err != nil { + return nil, err + } + reason, err := validateBusinessCatalogAppealReason(reason) + if err != nil { + return nil, err + } + return map[string]any{"input": map[string]any{ + "product_set_id": collectionID, "jid": jid.ToNonAD().String(), "reason": reason, + }}, nil +} + +func decodeBusinessCartEnabled(data json.RawMessage, expected bool) error { + var envelope struct { + Result *struct { + Enabled *bool `json:"cart_enabled"` + } `json:"xfb_whatsapp_smb_commerce_settings"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("decode business commerce settings response: %w", err) + } + if envelope.Result == nil || envelope.Result.Enabled == nil { + return fmt.Errorf("business commerce settings response is missing cart_enabled") + } + if *envelope.Result.Enabled != expected { + return fmt.Errorf("business commerce settings response returned cart_enabled=%t, expected %t", *envelope.Result.Enabled, expected) + } + return nil +} + +func (cli *Client) CreateBusinessCatalog(ctx context.Context) error { + jid, err := cli.ownBusinessJID() + if err != nil { + return err + } + variables, err := buildCreateBusinessCatalogVariables(jid) + if err != nil { + return err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessCreateCatalogDocumentID, variables) + if err != nil { + return fmt.Errorf("create business catalog: %w", err) + } + return decodeBusinessCatalogSuccess(data, "xfb_whatsapp_catalog_create") +} + +func (cli *Client) SetBusinessCartEnabled(ctx context.Context, enabled bool) error { + jid, err := cli.ownBusinessJID() + if err != nil { + return err + } + variables, err := buildBusinessCartVariables(jid, enabled) + if err != nil { + return err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessUpdateCommerceDocumentID, variables) + if err != nil { + return fmt.Errorf("update business cart setting: %w", err) + } + return decodeBusinessCartEnabled(data, enabled) +} + +func (cli *Client) SetBusinessProductVisibility(ctx context.Context, productID string, hidden bool) error { + jid, err := cli.ownBusinessJID() + if err != nil { + return err + } + variables, err := buildBusinessProductVisibilityVariables(jid, productID, hidden) + if err != nil { + return err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessProductVisibilityDocumentID, variables) + if err != nil { + return fmt.Errorf("update business product visibility: %w", err) + } + return decodeBusinessCatalogSuccess(data, "xfb_whatsapp_catalog_product_visibility_update") +} + +func (cli *Client) AppealBusinessProduct(ctx context.Context, productID, reason string) error { + jid, err := cli.ownBusinessJID() + if err != nil { + return err + } + variables, err := buildBusinessProductAppealVariables(jid, productID, reason) + if err != nil { + return err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessAppealProductDocumentID, variables) + if err != nil { + return fmt.Errorf("appeal business product: %w", err) + } + return decodeBusinessCatalogSuccess(data, "xfb_whatsapp_catalog_appeal_product") +} + +func (cli *Client) AppealBusinessCollection(ctx context.Context, collectionID, reason string) error { + jid, err := cli.ownBusinessJID() + if err != nil { + return err + } + variables, err := buildBusinessCollectionAppealVariables(jid, collectionID, reason) + if err != nil { + return err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessAppealCollectionDocumentID, variables) + if err != nil { + return fmt.Errorf("appeal business collection: %w", err) + } + return decodeBusinessCatalogSuccess(data, "xfb_whatsapp_catalog_appeal_collection") +} diff --git a/business_commerce_control_test.go b/business_commerce_control_test.go new file mode 100644 index 000000000..ddb17cd62 --- /dev/null +++ b/business_commerce_control_test.go @@ -0,0 +1,84 @@ +package whatsmeow + +import ( + "encoding/json" + "strings" + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestBuildBusinessCommerceControlVariables(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + created, err := buildCreateBusinessCatalogVariables(jid) + if err != nil { + t.Fatal(err) + } + createInput := created["input"].(map[string]any) + if createInput["platform"] != "WEB" || createInput["product_catalog"].(map[string]any)["biz_jid"] != jid.String() { + t.Fatalf("unexpected catalog create input: %#v", createInput) + } + cart, err := buildBusinessCartVariables(jid, false) + if err != nil || cart["input"].(map[string]any)["cart_enabled"] != false { + t.Fatalf("cart = %#v, error = %v", cart, err) + } + visibility, err := buildBusinessProductVisibilityVariables(jid, "product-1", true) + if err != nil { + t.Fatal(err) + } + product := visibility["input"].(map[string]any)["products"].([]map[string]any)[0] + if product["product_id"] != "product-1" || product["is_hidden"] != true { + t.Fatalf("unexpected visibility input: %#v", visibility) + } + productAppeal, err := buildBusinessProductAppealVariables(jid, "product-1", " incorrect rejection ") + if err != nil || productAppeal["input"].(map[string]any)["reason"] != "incorrect rejection" { + t.Fatalf("product appeal = %#v, error = %v", productAppeal, err) + } + collectionAppeal, err := buildBusinessCollectionAppealVariables(jid, "collection-1", "incorrect rejection") + if err != nil || collectionAppeal["input"].(map[string]any)["product_set_id"] != "collection-1" { + t.Fatalf("collection appeal = %#v, error = %v", collectionAppeal, err) + } +} + +func TestRejectInvalidBusinessCommerceControlVariables(t *testing.T) { + jid := types.NewJID("15551234567", types.DefaultUserServer) + if _, err := buildBusinessProductVisibilityVariables(jid, "", true); err == nil { + t.Fatal("empty product ID unexpectedly passed") + } + for _, reason := range []string{"", " ", strings.Repeat("r", maxBusinessCatalogAppealReasonBytes+1)} { + if _, err := buildBusinessProductAppealVariables(jid, "product-1", reason); err == nil { + t.Fatalf("invalid reason unexpectedly passed: %q", reason) + } + } + if _, err := buildBusinessCollectionAppealVariables(jid, "", "reason"); err == nil { + t.Fatal("empty collection ID unexpectedly passed") + } +} + +func TestDecodeBusinessCommerceControlResponses(t *testing.T) { + for _, discriminator := range []string{ + "xfb_whatsapp_catalog_create", + "xfb_whatsapp_catalog_product_visibility_update", + "xfb_whatsapp_catalog_appeal_product", + "xfb_whatsapp_catalog_appeal_collection", + } { + if err := decodeBusinessCatalogSuccess(json.RawMessage(`{"`+discriminator+`":{"success":true}}`), discriminator); err != nil { + t.Fatalf("%s success failed: %v", discriminator, err) + } + if err := decodeBusinessCatalogSuccess(json.RawMessage(`{"`+discriminator+`":{"success":false}}`), discriminator); err == nil { + t.Fatalf("%s false success unexpectedly passed", discriminator) + } + if err := decodeBusinessCatalogSuccess(json.RawMessage(`{}`), discriminator); err == nil { + t.Fatalf("%s missing response unexpectedly passed", discriminator) + } + } + if err := decodeBusinessCartEnabled(json.RawMessage(`{"xfb_whatsapp_smb_commerce_settings":{"cart_enabled":false}}`), false); err != nil { + t.Fatal(err) + } + if err := decodeBusinessCartEnabled(json.RawMessage(`{"xfb_whatsapp_smb_commerce_settings":{"cart_enabled":true}}`), false); err == nil { + t.Fatal("mismatched cart setting unexpectedly passed") + } + if err := decodeBusinessCartEnabled(json.RawMessage(`{}`), false); err == nil { + t.Fatal("missing cart setting unexpectedly passed") + } +} From ba1f743825eda861b85e8a2039ce62fb9228472a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 00:59:28 +0300 Subject: [PATCH 051/163] fix: decode created business catalogs --- business_collection_mutation.go | 12 ++++++++++++ business_commerce_control_test.go | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/business_collection_mutation.go b/business_collection_mutation.go index aedea24b0..3a734b2b7 100644 --- a/business_collection_mutation.go +++ b/business_collection_mutation.go @@ -199,6 +199,18 @@ func decodeBusinessCatalogSuccess(data json.RawMessage, discriminator string) er if !ok { return fmt.Errorf("business catalog response is missing %s", discriminator) } + if discriminator == "xfb_whatsapp_catalog_create" { + var response struct { + ProductCatalog *struct{} `json:"product_catalog"` + } + if err := json.Unmarshal(raw, &response); err != nil { + return fmt.Errorf("decode %s response: %w", discriminator, err) + } + if response.ProductCatalog == nil { + return fmt.Errorf("%s response is missing product_catalog", discriminator) + } + return nil + } var response struct { Success *bool `json:"success"` } diff --git a/business_commerce_control_test.go b/business_commerce_control_test.go index ddb17cd62..778e4ba6b 100644 --- a/business_commerce_control_test.go +++ b/business_commerce_control_test.go @@ -57,7 +57,6 @@ func TestRejectInvalidBusinessCommerceControlVariables(t *testing.T) { func TestDecodeBusinessCommerceControlResponses(t *testing.T) { for _, discriminator := range []string{ - "xfb_whatsapp_catalog_create", "xfb_whatsapp_catalog_product_visibility_update", "xfb_whatsapp_catalog_appeal_product", "xfb_whatsapp_catalog_appeal_collection", @@ -72,6 +71,12 @@ func TestDecodeBusinessCommerceControlResponses(t *testing.T) { t.Fatalf("%s missing response unexpectedly passed", discriminator) } } + if err := decodeBusinessCatalogSuccess(json.RawMessage(`{"xfb_whatsapp_catalog_create":{"product_catalog":{"id":"catalog-1"}}}`), "xfb_whatsapp_catalog_create"); err != nil { + t.Fatalf("catalog create response failed: %v", err) + } + if err := decodeBusinessCatalogSuccess(json.RawMessage(`{"xfb_whatsapp_catalog_create":{"success":true}}`), "xfb_whatsapp_catalog_create"); err == nil { + t.Fatal("catalog create response without product_catalog unexpectedly passed") + } if err := decodeBusinessCartEnabled(json.RawMessage(`{"xfb_whatsapp_smb_commerce_settings":{"cart_enabled":false}}`), false); err != nil { t.Fatal(err) } From ba09c3f1b98221f2539c8c6bc1def4b4c4fd024d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 13:01:09 +0300 Subject: [PATCH 052/163] Add merchant compliance operations --- business_merchant_compliance.go | 157 ++++++++++++++++++++++++++ business_merchant_compliance_test.go | 161 +++++++++++++++++++++++++++ types/business_catalog.go | 33 ++++++ 3 files changed, 351 insertions(+) create mode 100644 business_merchant_compliance.go create mode 100644 business_merchant_compliance_test.go diff --git a/business_merchant_compliance.go b/business_merchant_compliance.go new file mode 100644 index 000000000..a5dcb5d39 --- /dev/null +++ b/business_merchant_compliance.go @@ -0,0 +1,157 @@ +package whatsmeow + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "go.mau.fi/whatsmeow/types" +) + +const ( + businessCatalogGraphQLEndpoint = "https://graph.whatsapp.com/graphql/catalog" + businessCatalogGraphQLAccessToken = "WA|787118555984857|7bb1544a3599aa180ac9a3f7688ba243" + businessGetMerchantComplianceDocumentID = "25960403573553316" + businessSetMerchantComplianceDocumentID = "25188352884120072" + maxBusinessMerchantNameBytes = 256 + maxBusinessMerchantEmailBytes = 254 + maxBusinessMerchantPhoneBytes = 64 +) + +func validateBusinessMerchantEntityType(entityType types.BusinessMerchantEntityType) error { + switch entityType { + case types.BusinessMerchantEntitySoleProprietorship, + types.BusinessMerchantEntityPartnership, + types.BusinessMerchantEntityPrivateCompany, + types.BusinessMerchantEntityPublicCompany, + types.BusinessMerchantEntityLimitedLiabilityPartnership, + types.BusinessMerchantEntityOther: + return nil + default: + return fmt.Errorf("unsupported business merchant entity type %q", entityType) + } +} + +func validateBusinessMerchantField(name, value string, limit int) error { + if len(value) > limit { + return fmt.Errorf("business merchant %s exceeds %d bytes", name, limit) + } + return nil +} + +func normalizeBusinessMerchantCompliance(info types.BusinessMerchantCompliance) (types.BusinessMerchantCompliance, error) { + info.EntityName = strings.TrimSpace(info.EntityName) + info.EntityTypeCustom = strings.TrimSpace(info.EntityTypeCustom) + info.CustomerCare.Email = strings.TrimSpace(info.CustomerCare.Email) + info.CustomerCare.LandlineNumber = strings.TrimSpace(info.CustomerCare.LandlineNumber) + info.CustomerCare.MobileNumber = strings.TrimSpace(info.CustomerCare.MobileNumber) + info.GrievanceOfficer.Name = strings.TrimSpace(info.GrievanceOfficer.Name) + info.GrievanceOfficer.Email = strings.TrimSpace(info.GrievanceOfficer.Email) + info.GrievanceOfficer.LandlineNumber = strings.TrimSpace(info.GrievanceOfficer.LandlineNumber) + info.GrievanceOfficer.MobileNumber = strings.TrimSpace(info.GrievanceOfficer.MobileNumber) + if info.EntityType == "" { + info.EntityType = types.BusinessMerchantEntityOther + } + if err := validateBusinessMerchantEntityType(info.EntityType); err != nil { + return info, err + } + fields := []struct { + name string + value string + limit int + }{ + {"entity name", info.EntityName, maxBusinessMerchantNameBytes}, + {"custom entity type", info.EntityTypeCustom, maxBusinessMerchantNameBytes}, + {"customer care email", info.CustomerCare.Email, maxBusinessMerchantEmailBytes}, + {"customer care landline", info.CustomerCare.LandlineNumber, maxBusinessMerchantPhoneBytes}, + {"customer care mobile", info.CustomerCare.MobileNumber, maxBusinessMerchantPhoneBytes}, + {"grievance officer name", info.GrievanceOfficer.Name, maxBusinessMerchantNameBytes}, + {"grievance officer email", info.GrievanceOfficer.Email, maxBusinessMerchantEmailBytes}, + {"grievance officer landline", info.GrievanceOfficer.LandlineNumber, maxBusinessMerchantPhoneBytes}, + {"grievance officer mobile", info.GrievanceOfficer.MobileNumber, maxBusinessMerchantPhoneBytes}, + } + for _, field := range fields { + if err := validateBusinessMerchantField(field.name, field.value, field.limit); err != nil { + return info, err + } + } + return info, nil +} + +func buildBusinessMerchantComplianceQueryVariables(jid types.JID) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + return map[string]any{"request": map[string]any{"biz_jid": jid.ToNonAD().String()}}, nil +} + +func buildBusinessMerchantComplianceVariables(jid types.JID, info types.BusinessMerchantCompliance) (map[string]any, error) { + if err := validateBusinessJID(jid); err != nil { + return nil, err + } + info, err := normalizeBusinessMerchantCompliance(info) + if err != nil { + return nil, err + } + return map[string]any{"input": map[string]any{ + "biz_jid": jid.ToNonAD().String(), + "merchant_info": map[string]any{ + "entity_name": info.EntityName, "entity_type": string(info.EntityType), + "is_registered": info.IsRegistered, "entity_type_custom": info.EntityTypeCustom, + "customer_care_details": map[string]any{ + "email": info.CustomerCare.Email, "landline_number": info.CustomerCare.LandlineNumber, "mobile_number": info.CustomerCare.MobileNumber, + }, + "grievance_officer_details": map[string]any{ + "name": info.GrievanceOfficer.Name, "email": info.GrievanceOfficer.Email, + "landline_number": info.GrievanceOfficer.LandlineNumber, "mobile_number": info.GrievanceOfficer.MobileNumber, + }, + }, + }}, nil +} + +func decodeBusinessMerchantCompliance(data json.RawMessage, field string) (*types.BusinessMerchantCompliance, error) { + var envelope map[string]struct { + MerchantInfo *types.BusinessMerchantCompliance `json:"merchant_info"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, fmt.Errorf("decode business merchant compliance response: %w", err) + } + result, ok := envelope[field] + if !ok || result.MerchantInfo == nil { + return nil, fmt.Errorf("business merchant compliance response is missing merchant_info") + } + return result.MerchantInfo, nil +} + +func (cli *Client) GetBusinessMerchantCompliance(ctx context.Context) (*types.BusinessMerchantCompliance, error) { + jid, err := cli.ownBusinessJID() + if err != nil { + return nil, err + } + variables, err := buildBusinessMerchantComplianceQueryVariables(jid) + if err != nil { + return nil, err + } + data, err := cli.sendBusinessFacebookGraphQL(ctx, businessCatalogGraphQLEndpoint, businessGetMerchantComplianceDocumentID, businessCatalogGraphQLAccessToken, variables) + if err != nil { + return nil, fmt.Errorf("get business merchant compliance: %w", err) + } + return decodeBusinessMerchantCompliance(data, "xfb_whatsapp_biz_merchant_compliance_info") +} + +func (cli *Client) SetBusinessMerchantCompliance(ctx context.Context, info types.BusinessMerchantCompliance) (*types.BusinessMerchantCompliance, error) { + jid, err := cli.ownBusinessJID() + if err != nil { + return nil, err + } + variables, err := buildBusinessMerchantComplianceVariables(jid, info) + if err != nil { + return nil, err + } + data, err := cli.executeBusinessCatalogMutation(ctx, businessSetMerchantComplianceDocumentID, variables) + if err != nil { + return nil, fmt.Errorf("set business merchant compliance: %w", err) + } + return decodeBusinessMerchantCompliance(data, "xfb_whatsapp_biz_merchant_set_compliance_info") +} diff --git a/business_merchant_compliance_test.go b/business_merchant_compliance_test.go new file mode 100644 index 000000000..d098874f3 --- /dev/null +++ b/business_merchant_compliance_test.go @@ -0,0 +1,161 @@ +package whatsmeow + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" + waLog "go.mau.fi/whatsmeow/util/log" +) + +type merchantComplianceRoundTripper func(*http.Request) (*http.Response, error) + +func (fn merchantComplianceRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func syntheticMerchantCompliance() types.BusinessMerchantCompliance { + return types.BusinessMerchantCompliance{ + EntityName: "Polymorfa Labs", + EntityType: types.BusinessMerchantEntityPrivateCompany, + IsRegistered: true, + EntityTypeCustom: "", + CustomerCare: types.BusinessMerchantContact{ + Email: "support@example.test", + LandlineNumber: "+961 1 555 0100", + MobileNumber: "+961 70 555 010", + }, + GrievanceOfficer: types.BusinessMerchantOfficer{ + Name: "Compliance Desk", + Email: "appeals@example.test", + LandlineNumber: "+961 1 555 0101", + MobileNumber: "+961 70 555 011", + }, + } +} + +func TestBuildBusinessMerchantComplianceVariables(t *testing.T) { + got, err := buildBusinessMerchantComplianceVariables(types.NewJID("15550001111", types.DefaultUserServer), syntheticMerchantCompliance()) + if err != nil { + t.Fatal(err) + } + want := map[string]any{"input": map[string]any{ + "biz_jid": "15550001111@s.whatsapp.net", + "merchant_info": map[string]any{ + "entity_name": "Polymorfa Labs", + "entity_type": "PRIVATE_COMPANY", + "is_registered": true, + "entity_type_custom": "", + "customer_care_details": map[string]any{ + "email": "support@example.test", "landline_number": "+961 1 555 0100", "mobile_number": "+961 70 555 010", + }, + "grievance_officer_details": map[string]any{ + "name": "Compliance Desk", "email": "appeals@example.test", "landline_number": "+961 1 555 0101", "mobile_number": "+961 70 555 011", + }, + }, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected variables:\n got %#v\nwant %#v", got, want) + } +} + +func TestBuildBusinessMerchantComplianceQueryVariables(t *testing.T) { + got, err := buildBusinessMerchantComplianceQueryVariables(types.NewJID("15550001111", types.DefaultUserServer)) + if err != nil { + t.Fatal(err) + } + want := map[string]any{"request": map[string]any{"biz_jid": "15550001111@s.whatsapp.net"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected variables: got %#v want %#v", got, want) + } +} + +func TestDecodeBusinessMerchantCompliance(t *testing.T) { + data := json.RawMessage(`{"xfb_whatsapp_biz_merchant_compliance_info":{"merchant_info":{"entity_name":"Polymorfa Labs","entity_type":"PRIVATE_COMPANY","is_registered":true,"entity_type_custom":"","customer_care_details":{"email":"support@example.test","landline_number":"+961 1 555 0100","mobile_number":"+961 70 555 010"},"grievance_officer_details":{"name":"Compliance Desk","email":"appeals@example.test","landline_number":"+961 1 555 0101","mobile_number":"+961 70 555 011"}}}}`) + got, err := decodeBusinessMerchantCompliance(data, "xfb_whatsapp_biz_merchant_compliance_info") + if err != nil { + t.Fatal(err) + } + want := syntheticMerchantCompliance() + if !reflect.DeepEqual(*got, want) { + t.Fatalf("unexpected compliance response: got %#v want %#v", *got, want) + } +} + +func TestBusinessMerchantComplianceRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + mutate func(*types.BusinessMerchantCompliance) + }{ + {name: "entity type", mutate: func(info *types.BusinessMerchantCompliance) { info.EntityType = "COOPERATIVE" }}, + {name: "entity name length", mutate: func(info *types.BusinessMerchantCompliance) { info.EntityName = strings.Repeat("n", 257) }}, + {name: "customer email length", mutate: func(info *types.BusinessMerchantCompliance) { info.CustomerCare.Email = strings.Repeat("e", 255) }}, + {name: "officer phone length", mutate: func(info *types.BusinessMerchantCompliance) { + info.GrievanceOfficer.MobileNumber = strings.Repeat("1", 65) + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + info := syntheticMerchantCompliance() + tc.mutate(&info) + if _, err := buildBusinessMerchantComplianceVariables(types.NewJID("15550001111", types.DefaultUserServer), info); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestDecodeBusinessMerchantComplianceRejectsMissingPayload(t *testing.T) { + if _, err := decodeBusinessMerchantCompliance(json.RawMessage(`{"xfb_whatsapp_biz_merchant_compliance_info":{}}`), "xfb_whatsapp_biz_merchant_compliance_info"); err == nil { + t.Fatal("expected missing merchant_info error") + } +} + +func TestBusinessMerchantComplianceMethodsUseMatchingGraphEnvironments(t *testing.T) { + jid := types.NewJID("15550001111", types.DefaultUserServer) + client := NewClient(&store.Device{ID: &jid}, waLog.Noop) + client.getBusinessCatalogAuth().token = businessAccessToken{accessToken: "synthetic-ad-token"} + client.mediaHTTP = &http.Client{Transport: merchantComplianceRoundTripper(func(request *http.Request) (*http.Response, error) { + var body struct { + AccessToken string `json:"access_token"` + DocumentID string `json:"doc_id"` + Variables map[string]any `json:"variables"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + return nil, err + } + var payload string + switch body.DocumentID { + case businessGetMerchantComplianceDocumentID: + if request.URL.String() != businessCatalogGraphQLEndpoint || body.AccessToken != businessCatalogGraphQLAccessToken || body.Variables["request"] == nil { + return nil, fmt.Errorf("unexpected catalog query: %s %#v", request.URL, body) + } + payload = `{"data":{"xfb_whatsapp_biz_merchant_compliance_info":{"merchant_info":{"entity_name":"Polymorfa Labs","entity_type":"PRIVATE_COMPANY","is_registered":true,"entity_type_custom":"","customer_care_details":{},"grievance_officer_details":{}}}}}` + case businessSetMerchantComplianceDocumentID: + if request.URL.String() != businessGraphQLEndpoint || body.AccessToken != "synthetic-ad-token" || body.Variables["input"] == nil { + return nil, fmt.Errorf("unexpected Facebook mutation: %s %#v", request.URL, body) + } + payload = `{"data":{"xfb_whatsapp_biz_merchant_set_compliance_info":{"merchant_info":{"entity_name":"Polymorfa Labs","entity_type":"PRIVATE_COMPANY","is_registered":true,"entity_type_custom":"","customer_care_details":{},"grievance_officer_details":{}}}}}` + default: + return nil, fmt.Errorf("unexpected document ID %q", body.DocumentID) + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewBufferString(payload))}, nil + })} + + read, err := client.GetBusinessMerchantCompliance(context.Background()) + if err != nil || read.EntityName != "Polymorfa Labs" { + t.Fatalf("read = %#v, error = %v", read, err) + } + updated, err := client.SetBusinessMerchantCompliance(context.Background(), syntheticMerchantCompliance()) + if err != nil || updated.EntityType != types.BusinessMerchantEntityPrivateCompany { + t.Fatalf("updated = %#v, error = %v", updated, err) + } +} diff --git a/types/business_catalog.go b/types/business_catalog.go index daa474dc5..4ef73196d 100644 --- a/types/business_catalog.go +++ b/types/business_catalog.go @@ -49,6 +49,39 @@ type BusinessComplianceInfo struct { ImporterAddress *BusinessAddress `json:"importer_address,omitempty"` } +type BusinessMerchantEntityType string + +const ( + BusinessMerchantEntitySoleProprietorship BusinessMerchantEntityType = "SOLE_PROPRIETORSHIP" + BusinessMerchantEntityPartnership BusinessMerchantEntityType = "PARTNERSHIP" + BusinessMerchantEntityPrivateCompany BusinessMerchantEntityType = "PRIVATE_COMPANY" + BusinessMerchantEntityPublicCompany BusinessMerchantEntityType = "PUBLIC_COMPANY" + BusinessMerchantEntityLimitedLiabilityPartnership BusinessMerchantEntityType = "LIMITED_LIABILITY_PARTNERSHIP" + BusinessMerchantEntityOther BusinessMerchantEntityType = "OTHER" +) + +type BusinessMerchantContact struct { + Email string `json:"email"` + LandlineNumber string `json:"landline_number"` + MobileNumber string `json:"mobile_number"` +} + +type BusinessMerchantOfficer struct { + Name string `json:"name"` + Email string `json:"email"` + LandlineNumber string `json:"landline_number"` + MobileNumber string `json:"mobile_number"` +} + +type BusinessMerchantCompliance struct { + EntityName string `json:"entity_name"` + EntityType BusinessMerchantEntityType `json:"entity_type"` + IsRegistered bool `json:"is_registered"` + EntityTypeCustom string `json:"entity_type_custom"` + CustomerCare BusinessMerchantContact `json:"customer_care_details"` + GrievanceOfficer BusinessMerchantOfficer `json:"grievance_officer_details"` +} + type BusinessAddress struct { Street1 string `json:"street1,omitempty"` Street2 string `json:"street2,omitempty"` From b4de29890eeb74da5b07e0ac94c10f31806d1f14 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:15:17 +0300 Subject: [PATCH 053/163] fix: require merchant entity names --- business_merchant_compliance.go | 3 +++ business_merchant_compliance_test.go | 1 + 2 files changed, 4 insertions(+) diff --git a/business_merchant_compliance.go b/business_merchant_compliance.go index a5dcb5d39..1378f432b 100644 --- a/business_merchant_compliance.go +++ b/business_merchant_compliance.go @@ -53,6 +53,9 @@ func normalizeBusinessMerchantCompliance(info types.BusinessMerchantCompliance) if info.EntityType == "" { info.EntityType = types.BusinessMerchantEntityOther } + if info.EntityName == "" { + return info, fmt.Errorf("business merchant entity name is empty") + } if err := validateBusinessMerchantEntityType(info.EntityType); err != nil { return info, err } diff --git a/business_merchant_compliance_test.go b/business_merchant_compliance_test.go index d098874f3..c25d54e59 100644 --- a/business_merchant_compliance_test.go +++ b/business_merchant_compliance_test.go @@ -96,6 +96,7 @@ func TestBusinessMerchantComplianceRejectsInvalidInput(t *testing.T) { mutate func(*types.BusinessMerchantCompliance) }{ {name: "entity type", mutate: func(info *types.BusinessMerchantCompliance) { info.EntityType = "COOPERATIVE" }}, + {name: "empty entity name", mutate: func(info *types.BusinessMerchantCompliance) { info.EntityName = " " }}, {name: "entity name length", mutate: func(info *types.BusinessMerchantCompliance) { info.EntityName = strings.Repeat("n", 257) }}, {name: "customer email length", mutate: func(info *types.BusinessMerchantCompliance) { info.CustomerCare.Email = strings.Repeat("e", 255) }}, {name: "officer phone length", mutate: func(info *types.BusinessMerchantCompliance) { From 04b36f003b7513469c1f87adfe7fe8046eec81e7 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 04:09:31 +0300 Subject: [PATCH 054/163] test: seed business mutation actor --- business_merchant_compliance_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/business_merchant_compliance_test.go b/business_merchant_compliance_test.go index c25d54e59..a73155c78 100644 --- a/business_merchant_compliance_test.go +++ b/business_merchant_compliance_test.go @@ -123,7 +123,7 @@ func TestDecodeBusinessMerchantComplianceRejectsMissingPayload(t *testing.T) { func TestBusinessMerchantComplianceMethodsUseMatchingGraphEnvironments(t *testing.T) { jid := types.NewJID("15550001111", types.DefaultUserServer) client := NewClient(&store.Device{ID: &jid}, waLog.Noop) - client.getBusinessCatalogAuth().token = businessAccessToken{accessToken: "synthetic-ad-token"} + client.getBusinessCatalogAuth().token = businessAccessToken{accessToken: "synthetic-ad-token", actorID: "synthetic-actor"} client.mediaHTTP = &http.Client{Transport: merchantComplianceRoundTripper(func(request *http.Request) (*http.Response, error) { var body struct { AccessToken string `json:"access_token"` @@ -141,7 +141,8 @@ func TestBusinessMerchantComplianceMethodsUseMatchingGraphEnvironments(t *testin } payload = `{"data":{"xfb_whatsapp_biz_merchant_compliance_info":{"merchant_info":{"entity_name":"Polymorfa Labs","entity_type":"PRIVATE_COMPANY","is_registered":true,"entity_type_custom":"","customer_care_details":{},"grievance_officer_details":{}}}}}` case businessSetMerchantComplianceDocumentID: - if request.URL.String() != businessGraphQLEndpoint || body.AccessToken != "synthetic-ad-token" || body.Variables["input"] == nil { + input, _ := body.Variables["input"].(map[string]any) + if request.URL.String() != businessGraphQLEndpoint || body.AccessToken != "synthetic-ad-token" || input["actor_id"] != "synthetic-actor" { return nil, fmt.Errorf("unexpected Facebook mutation: %s %#v", request.URL, body) } payload = `{"data":{"xfb_whatsapp_biz_merchant_set_compliance_info":{"merchant_info":{"entity_name":"Polymorfa Labs","entity_type":"PRIVATE_COMPANY","is_registered":true,"entity_type_custom":"","customer_care_details":{},"grievance_officer_details":{}}}}}` From 27ba46fe0df7222d47a941b0b38243e66f4543ac Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 10:18:56 +0300 Subject: [PATCH 055/163] fix: require merchant entity classification --- business_merchant_compliance.go | 9 ++++++--- business_merchant_compliance_test.go | 5 +++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/business_merchant_compliance.go b/business_merchant_compliance.go index 1378f432b..1ad0bc91e 100644 --- a/business_merchant_compliance.go +++ b/business_merchant_compliance.go @@ -50,15 +50,18 @@ func normalizeBusinessMerchantCompliance(info types.BusinessMerchantCompliance) info.GrievanceOfficer.Email = strings.TrimSpace(info.GrievanceOfficer.Email) info.GrievanceOfficer.LandlineNumber = strings.TrimSpace(info.GrievanceOfficer.LandlineNumber) info.GrievanceOfficer.MobileNumber = strings.TrimSpace(info.GrievanceOfficer.MobileNumber) - if info.EntityType == "" { - info.EntityType = types.BusinessMerchantEntityOther - } if info.EntityName == "" { return info, fmt.Errorf("business merchant entity name is empty") } + if info.EntityType == "" { + return info, fmt.Errorf("business merchant entity type is empty") + } if err := validateBusinessMerchantEntityType(info.EntityType); err != nil { return info, err } + if info.EntityType == types.BusinessMerchantEntityOther && info.EntityTypeCustom == "" { + return info, fmt.Errorf("business merchant custom entity type is empty") + } fields := []struct { name string value string diff --git a/business_merchant_compliance_test.go b/business_merchant_compliance_test.go index a73155c78..0e5abbb68 100644 --- a/business_merchant_compliance_test.go +++ b/business_merchant_compliance_test.go @@ -96,6 +96,11 @@ func TestBusinessMerchantComplianceRejectsInvalidInput(t *testing.T) { mutate func(*types.BusinessMerchantCompliance) }{ {name: "entity type", mutate: func(info *types.BusinessMerchantCompliance) { info.EntityType = "COOPERATIVE" }}, + {name: "missing entity type", mutate: func(info *types.BusinessMerchantCompliance) { info.EntityType = "" }}, + {name: "missing custom entity type", mutate: func(info *types.BusinessMerchantCompliance) { + info.EntityType = types.BusinessMerchantEntityOther + info.EntityTypeCustom = " " + }}, {name: "empty entity name", mutate: func(info *types.BusinessMerchantCompliance) { info.EntityName = " " }}, {name: "entity name length", mutate: func(info *types.BusinessMerchantCompliance) { info.EntityName = strings.Repeat("n", 257) }}, {name: "customer email length", mutate: func(info *types.BusinessMerchantCompliance) { info.CustomerCare.Email = strings.Repeat("e", 255) }}, From b4be587b17149523dac4cdc12a1ff7f63dc0cf9c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 13:38:26 +0300 Subject: [PATCH 056/163] Add quick reply app state support --- appstate.go | 22 ++++++++++++--- appstate/encode.go | 37 +++++++++++++++++++++++++ appstate/encode_quick_reply_test.go | 42 +++++++++++++++++++++++++++++ appstate_label_events_test.go | 40 +++++++++++++++++++++++++++ client.go | 7 ++--- types/events/appstate.go | 9 +++++++ 6 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 appstate/encode_quick_reply_test.go diff --git a/appstate.go b/appstate.go index 37abd01c8..1cf1b4786 100644 --- a/appstate.go +++ b/appstate.go @@ -67,7 +67,7 @@ func (cli *Client) fetchAppState(ctx context.Context, name appstate.WAPatchName, wantSnapshot := fullSync var eventsToDispatch []any eventsToDispatchPtr := &eventsToDispatch - if fullSync && !cli.EmitAppStateEventsOnFullSync && !cli.EmitLabelEventsOnFullSync { + if fullSync && !cli.EmitAppStateEventsOnFullSync && !cli.EmitLabelEventsOnFullSync && !cli.EmitQuickReplyEventsOnFullSync { eventsToDispatchPtr = nil } for hasMore { @@ -108,7 +108,7 @@ func (cli *Client) handleAppStateRecovery( } var eventsToDispatch []any eventsToDispatchPtr := &eventsToDispatch - if !cli.EmitAppStateEventsOnFullSync && !cli.EmitLabelEventsOnFullSync { + if !cli.EmitAppStateEventsOnFullSync && !cli.EmitLabelEventsOnFullSync && !cli.EmitQuickReplyEventsOnFullSync { eventsToDispatchPtr = nil } snapshot, err := appstate.ParseRecovery(result[0].GetSyncdSnapshotFatalRecoveryResponse()) @@ -202,7 +202,13 @@ func (cli *Client) shouldEmitFullSyncMutation(index []string) bool { if cli.EmitAppStateEventsOnFullSync { return true } - if !cli.EmitLabelEventsOnFullSync || len(index) == 0 { + if len(index) == 0 { + return false + } + if cli.EmitQuickReplyEventsOnFullSync && index[0] == appstate.IndexQuickReply { + return true + } + if !cli.EmitLabelEventsOnFullSync { return false } switch index[0] { @@ -433,6 +439,16 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa Action: act, FromFullSync: fullSync, } + case appstate.IndexQuickReply: + if len(mutation.Index) < 2 { + return + } + eventToDispatch = &events.QuickReply{ + Timestamp: ts, + ID: mutation.Index[1], + Action: mutation.Action.GetQuickReplyAction(), + FromFullSync: fullSync, + } } if storeUpdateError != nil { cli.Log.Errorf("Failed to update device store after app state mutation: %v", storeUpdateError) diff --git a/appstate/encode.go b/appstate/encode.go index 8a3226ce3..610f9e2c3 100644 --- a/appstate/encode.go +++ b/appstate/encode.go @@ -228,6 +228,43 @@ func BuildLabelEdit(labelID string, labelName string, labelColor int32, deleted } } +func newQuickReplyMutation(id, shortcut, message string, keywords []string, count int32, deleted bool) MutationInfo { + return MutationInfo{ + Index: []string{IndexQuickReply, id}, + Version: 2, + Value: &waSyncAction.SyncActionValue{ + QuickReplyAction: &waSyncAction.QuickReplyAction{ + Shortcut: proto.String(shortcut), + Message: proto.String(message), + Keywords: keywords, + Count: proto.Int32(count), + Deleted: proto.Bool(deleted), + AssociatedLabelIDs: []string{}, + }, + }, + } +} + +// BuildQuickReply builds an app state patch for adding or editing a quick reply. +func BuildQuickReply(id, shortcut, message string, keywords []string, count int32) PatchInfo { + return PatchInfo{ + Type: WAPatchRegular, + Mutations: []MutationInfo{ + newQuickReplyMutation(id, shortcut, message, keywords, count, false), + }, + } +} + +// BuildQuickReplyDelete builds an app state tombstone for deleting a quick reply. +func BuildQuickReplyDelete(id string) PatchInfo { + return PatchInfo{ + Type: WAPatchRegular, + Mutations: []MutationInfo{ + newQuickReplyMutation(id, "", "", []string{}, 0, true), + }, + } +} + func newSettingPushNameMutation(pushName string) MutationInfo { return MutationInfo{ Index: []string{IndexSettingPushName}, diff --git a/appstate/encode_quick_reply_test.go b/appstate/encode_quick_reply_test.go new file mode 100644 index 000000000..09e81ea85 --- /dev/null +++ b/appstate/encode_quick_reply_test.go @@ -0,0 +1,42 @@ +package appstate + +import ( + "reflect" + "testing" +) + +func TestBuildQuickReply(t *testing.T) { + patch := BuildQuickReply("1700000000", "hours", "We are open until 18:00.", []string{"open", "hours"}, 7) + if patch.Type != WAPatchRegular || len(patch.Mutations) != 1 { + t.Fatalf("patch = %#v", patch) + } + mutation := patch.Mutations[0] + if mutation.Version != 2 || !reflect.DeepEqual(mutation.Index, []string{IndexQuickReply, "1700000000"}) { + t.Fatalf("mutation = %#v", mutation) + } + action := mutation.Value.GetQuickReplyAction() + if action.GetDeleted() || action.GetShortcut() != "hours" || action.GetMessage() != "We are open until 18:00." || action.GetCount() != 7 { + t.Fatalf("action = %#v", action) + } + if !reflect.DeepEqual(action.GetKeywords(), []string{"open", "hours"}) || len(action.GetAssociatedLabelIDs()) != 0 { + t.Fatalf("action lists = %#v", action) + } +} + +func TestBuildQuickReplyDeleteUsesTombstone(t *testing.T) { + patch := BuildQuickReplyDelete("1700000000") + if patch.Type != WAPatchRegular || len(patch.Mutations) != 1 { + t.Fatalf("patch = %#v", patch) + } + mutation := patch.Mutations[0] + if mutation.Version != 2 || !reflect.DeepEqual(mutation.Index, []string{IndexQuickReply, "1700000000"}) { + t.Fatalf("mutation = %#v", mutation) + } + action := mutation.Value.GetQuickReplyAction() + if !action.GetDeleted() || action.GetShortcut() != "" || action.GetMessage() != "" || action.GetCount() != 0 { + t.Fatalf("action = %#v", action) + } + if len(action.GetKeywords()) != 0 || len(action.GetAssociatedLabelIDs()) != 0 { + t.Fatalf("action lists = %#v", action) + } +} diff --git a/appstate_label_events_test.go b/appstate_label_events_test.go index b8d98c6a6..145f7ebbf 100644 --- a/appstate_label_events_test.go +++ b/appstate_label_events_test.go @@ -1,9 +1,15 @@ package whatsmeow import ( + "context" "testing" + "time" "go.mau.fi/whatsmeow/appstate" + "go.mau.fi/whatsmeow/proto/waServerSync" + "go.mau.fi/whatsmeow/proto/waSyncAction" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" ) func TestSelectiveFullSyncLabelEvents(t *testing.T) { @@ -26,4 +32,38 @@ func TestSelectiveFullSyncLabelEvents(t *testing.T) { if !client.shouldEmitFullSyncMutation([]string{appstate.IndexMute}) { t.Fatal("full event mode did not emit non-label mutation") } + client.EmitAppStateEventsOnFullSync = false + client.EmitQuickReplyEventsOnFullSync = true + if !client.shouldEmitFullSyncMutation([]string{appstate.IndexQuickReply}) { + t.Fatal("quick reply event mode did not emit quick reply mutation") + } + if client.shouldEmitFullSyncMutation([]string{appstate.IndexMute}) { + t.Fatal("quick reply event mode emitted unrelated mutation") + } +} + +func TestQuickReplyAppStateEvent(t *testing.T) { + client := &Client{} + timestamp := int64(1700000000000) + got := client.dispatchAppState(context.Background(), appstate.WAPatchRegular, appstate.Mutation{ + Operation: waServerSync.SyncdMutation_SET, + Index: []string{appstate.IndexQuickReply, "1700000000"}, + Action: &waSyncAction.SyncActionValue{ + Timestamp: proto.Int64(timestamp), + QuickReplyAction: &waSyncAction.QuickReplyAction{ + Shortcut: proto.String("hours"), + Message: proto.String("We are open until 18:00."), + }, + }, + }, true) + event, ok := got.(*events.QuickReply) + if !ok { + t.Fatalf("event = %T, want *events.QuickReply", got) + } + if event.ID != "1700000000" || event.Timestamp != time.UnixMilli(timestamp) || !event.FromFullSync { + t.Fatalf("event = %#v", event) + } + if event.Action.GetShortcut() != "hours" || event.Action.GetMessage() != "We are open until 18:00." { + t.Fatalf("action = %#v", event.Action) + } } diff --git a/client.go b/client.go index dac3ecc9b..0f32524ae 100644 --- a/client.go +++ b/client.go @@ -124,9 +124,10 @@ type Client struct { // EmitAppStateEventsOnFullSync can be set to true if you want to get app state events emitted // even when re-syncing the whole state. - EmitAppStateEventsOnFullSync bool - EmitLabelEventsOnFullSync bool - AppStateDebugLogs bool + EmitAppStateEventsOnFullSync bool + EmitLabelEventsOnFullSync bool + EmitQuickReplyEventsOnFullSync bool + AppStateDebugLogs bool AutomaticMessageRerequestFromPhone bool pendingPhoneRerequests map[types.MessageID]context.CancelFunc diff --git a/types/events/appstate.go b/types/events/appstate.go index a6082743b..1e69a3e42 100644 --- a/types/events/appstate.go +++ b/types/events/appstate.go @@ -175,6 +175,15 @@ type LabelAssociationMessage struct { FromFullSync bool // Whether the action is emitted because of a fullSync } +// QuickReply is emitted when a quick reply is changed from any device. +type QuickReply struct { + Timestamp time.Time + ID string + + Action *waSyncAction.QuickReplyAction + FromFullSync bool +} + // AppState is emitted directly for new data received from app state syncing. // You should generally use the higher-level events like events.Contact and events.Mute. type AppState struct { From 579c8757828ad80cf810607a61a907f4f9eeb26b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 00:42:14 +0300 Subject: [PATCH 057/163] build: format label event test --- appstate_label_events_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/appstate_label_events_test.go b/appstate_label_events_test.go index 145f7ebbf..7ec7debe7 100644 --- a/appstate_label_events_test.go +++ b/appstate_label_events_test.go @@ -5,11 +5,12 @@ import ( "testing" "time" + "google.golang.org/protobuf/proto" + "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/proto/waServerSync" "go.mau.fi/whatsmeow/proto/waSyncAction" "go.mau.fi/whatsmeow/types/events" - "google.golang.org/protobuf/proto" ) func TestSelectiveFullSyncLabelEvents(t *testing.T) { From ceea524ea1dfb00de6701c26a192a904037ce0d4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:00:38 +0300 Subject: [PATCH 058/163] fix: preserve quick reply label associations --- appstate/encode.go | 10 +++++----- appstate/encode_quick_reply_test.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/appstate/encode.go b/appstate/encode.go index 610f9e2c3..bf9946963 100644 --- a/appstate/encode.go +++ b/appstate/encode.go @@ -228,7 +228,7 @@ func BuildLabelEdit(labelID string, labelName string, labelColor int32, deleted } } -func newQuickReplyMutation(id, shortcut, message string, keywords []string, count int32, deleted bool) MutationInfo { +func newQuickReplyMutation(id, shortcut, message string, keywords []string, count int32, deleted bool, associatedLabelIDs []string) MutationInfo { return MutationInfo{ Index: []string{IndexQuickReply, id}, Version: 2, @@ -239,18 +239,18 @@ func newQuickReplyMutation(id, shortcut, message string, keywords []string, coun Keywords: keywords, Count: proto.Int32(count), Deleted: proto.Bool(deleted), - AssociatedLabelIDs: []string{}, + AssociatedLabelIDs: associatedLabelIDs, }, }, } } // BuildQuickReply builds an app state patch for adding or editing a quick reply. -func BuildQuickReply(id, shortcut, message string, keywords []string, count int32) PatchInfo { +func BuildQuickReply(id, shortcut, message string, keywords []string, count int32, associatedLabelIDs ...string) PatchInfo { return PatchInfo{ Type: WAPatchRegular, Mutations: []MutationInfo{ - newQuickReplyMutation(id, shortcut, message, keywords, count, false), + newQuickReplyMutation(id, shortcut, message, keywords, count, false, associatedLabelIDs), }, } } @@ -260,7 +260,7 @@ func BuildQuickReplyDelete(id string) PatchInfo { return PatchInfo{ Type: WAPatchRegular, Mutations: []MutationInfo{ - newQuickReplyMutation(id, "", "", []string{}, 0, true), + newQuickReplyMutation(id, "", "", []string{}, 0, true, nil), }, } } diff --git a/appstate/encode_quick_reply_test.go b/appstate/encode_quick_reply_test.go index 09e81ea85..964459def 100644 --- a/appstate/encode_quick_reply_test.go +++ b/appstate/encode_quick_reply_test.go @@ -23,6 +23,25 @@ func TestBuildQuickReply(t *testing.T) { } } +func TestBuildQuickReplyPreservesAssociatedLabels(t *testing.T) { + build := reflect.ValueOf(BuildQuickReply) + if !build.Type().IsVariadic() { + t.Fatal("BuildQuickReply does not accept associated label IDs") + } + result := build.CallSlice([]reflect.Value{ + reflect.ValueOf("1700000000"), + reflect.ValueOf("hours"), + reflect.ValueOf("We are open until 18:00."), + reflect.ValueOf([]string{"open", "hours"}), + reflect.ValueOf(int32(7)), + reflect.ValueOf([]string{"label-1", "label-2"}), + })[0].Interface().(PatchInfo) + action := result.Mutations[0].Value.GetQuickReplyAction() + if !reflect.DeepEqual(action.GetAssociatedLabelIDs(), []string{"label-1", "label-2"}) { + t.Fatalf("associated labels = %#v", action.GetAssociatedLabelIDs()) + } +} + func TestBuildQuickReplyDeleteUsesTombstone(t *testing.T) { patch := BuildQuickReplyDelete("1700000000") if patch.Type != WAPatchRegular || len(patch.Mutations) != 1 { From 1639950b4dc319b164f910b9ee8eb5761d09aabc Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 14:12:52 +0300 Subject: [PATCH 059/163] feat: add typed business message builders --- business_message_builders.go | 286 ++++++++++++++++++++++++++++++ business_message_builders_test.go | 114 ++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 business_message_builders.go create mode 100644 business_message_builders_test.go diff --git a/business_message_builders.go b/business_message_builders.go new file mode 100644 index 000000000..95422b8e2 --- /dev/null +++ b/business_message_builders.go @@ -0,0 +1,286 @@ +package whatsmeow + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" +) + +type BusinessProductMessageParams struct { + BusinessOwnerJID types.JID + ProductID string + Title string + Description string + CurrencyCode string + PriceAmount1000 int64 + SalePriceAmount1000 int64 + RetailerID string + URL string + ProductImageCount uint32 + ProductImage *waE2E.ImageMessage + Body string + Footer string +} + +type BusinessProductSection struct { + Title string + ProductIDs []string +} + +type BusinessProductListMessageParams struct { + BusinessOwnerJID types.JID + Title string + Description string + ButtonText string + Footer string + Sections []BusinessProductSection +} + +type BusinessOrderMessageParams struct { + OrderID string + Thumbnail []byte + ItemCount int32 + Status waE2E.OrderMessage_OrderStatus + Message string + OrderTitle string + SellerJID types.JID + Token string + TotalAmount1000 int64 + TotalCurrencyCode string + CatalogType string +} + +type BusinessListRow struct { + ID string + Title string + Description string +} + +type BusinessListSection struct { + Title string + Rows []BusinessListRow +} + +type BusinessListMessageParams struct { + Title string + Description string + ButtonText string + Footer string + Sections []BusinessListSection +} + +type BusinessNativeFlowButton struct { + Name string + ParamsJSON string +} + +type BusinessNativeFlowButtonsMessageParams struct { + Title string + Body string + Footer string + Buttons []BusinessNativeFlowButton +} + +func validBusinessOwner(jid types.JID) bool { + return !jid.IsEmpty() && jid.Server == types.DefaultUserServer && jid.Device == 0 +} + +func validCurrency(code string) bool { + if len(code) != 3 { + return false + } + for _, char := range code { + if char < 'A' || char > 'Z' { + return false + } + } + return true +} + +func bounded(value string, max int) bool { + return len(value) <= max +} + +func optionalString(value string) *string { + if value == "" { + return nil + } + return proto.String(value) +} + +func optionalPositiveInt64(value int64) *int64 { + if value == 0 { + return nil + } + return proto.Int64(value) +} + +func optionalPositiveUint32(value uint32) *uint32 { + if value == 0 { + return nil + } + return proto.Uint32(value) +} + +func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Message, error) { + if !validBusinessOwner(params.BusinessOwnerJID) { + return nil, errors.New("invalid business owner JID") + } + if strings.TrimSpace(params.ProductID) == "" || !bounded(params.ProductID, 256) || strings.TrimSpace(params.Title) == "" || !bounded(params.Title, 256) { + return nil, errors.New("invalid business product identity") + } + if !bounded(params.Description, 4096) || !bounded(params.RetailerID, 256) || !bounded(params.URL, 2048) || !bounded(params.Body, 4096) || !bounded(params.Footer, 256) { + return nil, errors.New("business product message field is too large") + } + if !validCurrency(params.CurrencyCode) || params.PriceAmount1000 < 0 || params.SalePriceAmount1000 < 0 { + return nil, errors.New("invalid business product price") + } + return &waE2E.Message{ProductMessage: &waE2E.ProductMessage{ + Product: &waE2E.ProductMessage_ProductSnapshot{ + ProductImage: params.ProductImage, ProductID: proto.String(params.ProductID), Title: proto.String(params.Title), + Description: optionalString(params.Description), CurrencyCode: proto.String(params.CurrencyCode), + PriceAmount1000: optionalPositiveInt64(params.PriceAmount1000), SalePriceAmount1000: optionalPositiveInt64(params.SalePriceAmount1000), + RetailerID: optionalString(params.RetailerID), URL: optionalString(params.URL), ProductImageCount: optionalPositiveUint32(params.ProductImageCount), + }, + BusinessOwnerJID: proto.String(params.BusinessOwnerJID.String()), Body: optionalString(params.Body), Footer: optionalString(params.Footer), + }}, nil +} + +func BuildBusinessProductListMessage(params BusinessProductListMessageParams) (*waE2E.Message, error) { + if !validBusinessOwner(params.BusinessOwnerJID) { + return nil, errors.New("invalid business owner JID") + } + if strings.TrimSpace(params.Title) == "" || !bounded(params.Title, 256) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 64) || !bounded(params.Description, 4096) || !bounded(params.Footer, 256) { + return nil, errors.New("invalid business product list text") + } + if len(params.Sections) == 0 || len(params.Sections) > 10 { + return nil, errors.New("business product list must contain 1 to 10 sections") + } + sections := make([]*waE2E.ListMessage_ProductSection, len(params.Sections)) + seen := make(map[string]struct{}) + productCount := 0 + for index, section := range params.Sections { + if !bounded(section.Title, 256) || len(section.ProductIDs) == 0 { + return nil, fmt.Errorf("invalid business product section %d", index) + } + products := make([]*waE2E.ListMessage_Product, len(section.ProductIDs)) + for productIndex, productID := range section.ProductIDs { + if strings.TrimSpace(productID) == "" || !bounded(productID, 256) { + return nil, fmt.Errorf("invalid product ID in section %d", index) + } + if _, exists := seen[productID]; exists { + return nil, fmt.Errorf("duplicate product ID %q", productID) + } + seen[productID] = struct{}{} + products[productIndex] = &waE2E.ListMessage_Product{ProductID: proto.String(productID)} + productCount++ + } + sections[index] = &waE2E.ListMessage_ProductSection{Title: optionalString(section.Title), Products: products} + } + if productCount > 30 { + return nil, errors.New("business product list exceeds 30 products") + } + return &waE2E.Message{ListMessage: &waE2E.ListMessage{ + Title: proto.String(params.Title), Description: optionalString(params.Description), ButtonText: proto.String(params.ButtonText), + ListType: waE2E.ListMessage_PRODUCT_LIST.Enum(), FooterText: optionalString(params.Footer), + ProductListInfo: &waE2E.ListMessage_ProductListInfo{ProductSections: sections, BusinessOwnerJID: proto.String(params.BusinessOwnerJID.String())}, + }}, nil +} + +func BuildBusinessOrderMessage(params BusinessOrderMessageParams) (*waE2E.Message, error) { + if !validBusinessOwner(params.SellerJID) { + return nil, errors.New("invalid seller JID") + } + if strings.TrimSpace(params.OrderID) == "" || !bounded(params.OrderID, 256) || params.ItemCount < 1 || params.ItemCount > 100 { + return nil, errors.New("invalid business order identity") + } + if params.Status < waE2E.OrderMessage_INQUIRY || params.Status > waE2E.OrderMessage_DECLINED || params.TotalAmount1000 < 0 || !validCurrency(params.TotalCurrencyCode) { + return nil, errors.New("invalid business order state") + } + if len(params.Thumbnail) > 64*1024 || !bounded(params.Message, 4096) || !bounded(params.OrderTitle, 256) || !bounded(params.Token, 8192) || !bounded(params.CatalogType, 128) { + return nil, errors.New("business order message field is too large") + } + return &waE2E.Message{OrderMessage: &waE2E.OrderMessage{ + OrderID: proto.String(params.OrderID), Thumbnail: params.Thumbnail, ItemCount: proto.Int32(params.ItemCount), + Status: params.Status.Enum(), Surface: waE2E.OrderMessage_CATALOG.Enum(), Message: optionalString(params.Message), + OrderTitle: optionalString(params.OrderTitle), SellerJID: proto.String(params.SellerJID.String()), Token: optionalString(params.Token), + TotalAmount1000: proto.Int64(params.TotalAmount1000), TotalCurrencyCode: proto.String(params.TotalCurrencyCode), CatalogType: optionalString(params.CatalogType), + }}, nil +} + +func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, error) { + if strings.TrimSpace(params.Title) == "" || !bounded(params.Title, 256) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 64) || !bounded(params.Description, 4096) || !bounded(params.Footer, 256) { + return nil, errors.New("invalid business list text") + } + if len(params.Sections) == 0 || len(params.Sections) > 10 { + return nil, errors.New("business list must contain 1 to 10 sections") + } + sections := make([]*waE2E.ListMessage_Section, len(params.Sections)) + seen := make(map[string]struct{}) + rowCount := 0 + for sectionIndex, section := range params.Sections { + if !bounded(section.Title, 256) || len(section.Rows) == 0 { + return nil, fmt.Errorf("invalid business list section %d", sectionIndex) + } + rows := make([]*waE2E.ListMessage_Row, len(section.Rows)) + for rowIndex, row := range section.Rows { + if strings.TrimSpace(row.ID) == "" || !bounded(row.ID, 256) || strings.TrimSpace(row.Title) == "" || !bounded(row.Title, 256) || !bounded(row.Description, 1024) { + return nil, fmt.Errorf("invalid business list row %d in section %d", rowIndex, sectionIndex) + } + if _, exists := seen[row.ID]; exists { + return nil, fmt.Errorf("duplicate business list row ID %q", row.ID) + } + seen[row.ID] = struct{}{} + rows[rowIndex] = &waE2E.ListMessage_Row{RowID: proto.String(row.ID), Title: proto.String(row.Title), Description: optionalString(row.Description)} + rowCount++ + } + sections[sectionIndex] = &waE2E.ListMessage_Section{Title: optionalString(section.Title), Rows: rows} + } + if rowCount > 30 { + return nil, errors.New("business list exceeds 30 rows") + } + return &waE2E.Message{ListMessage: &waE2E.ListMessage{ + Title: proto.String(params.Title), Description: optionalString(params.Description), ButtonText: proto.String(params.ButtonText), + ListType: waE2E.ListMessage_SINGLE_SELECT.Enum(), Sections: sections, FooterText: optionalString(params.Footer), + }}, nil +} + +func BuildBusinessNativeFlowButtonsMessage(params BusinessNativeFlowButtonsMessageParams) (*waE2E.Message, error) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 4096) || !bounded(params.Title, 256) || !bounded(params.Footer, 256) { + return nil, errors.New("invalid business native-flow text") + } + if len(params.Buttons) == 0 || len(params.Buttons) > 3 { + return nil, errors.New("business native-flow message must contain 1 to 3 buttons") + } + buttons := make([]*waE2E.ButtonsMessage_Button, len(params.Buttons)) + for index, button := range params.Buttons { + if strings.TrimSpace(button.Name) == "" || !bounded(button.Name, 64) || strings.TrimSpace(button.ParamsJSON) == "" || !bounded(button.ParamsJSON, 8192) { + return nil, fmt.Errorf("invalid business native-flow button %d", index) + } + var object map[string]any + if err := json.Unmarshal([]byte(button.ParamsJSON), &object); err != nil || object == nil { + return nil, fmt.Errorf("invalid business native-flow params for button %d", index) + } + buttons[index] = &waE2E.ButtonsMessage_Button{ + Type: waE2E.ButtonsMessage_Button_NATIVE_FLOW.Enum(), + NativeFlowInfo: &waE2E.ButtonsMessage_Button_NativeFlowInfo{ + Name: proto.String(button.Name), ParamsJSON: proto.String(button.ParamsJSON), + }, + } + } + headerType := waE2E.ButtonsMessage_EMPTY + message := &waE2E.ButtonsMessage{ + ContentText: proto.String(params.Body), FooterText: optionalString(params.Footer), Buttons: buttons, HeaderType: headerType.Enum(), + } + if params.Title != "" { + headerType = waE2E.ButtonsMessage_TEXT + message.HeaderType = headerType.Enum() + message.Header = &waE2E.ButtonsMessage_Text{Text: params.Title} + } + return &waE2E.Message{ButtonsMessage: message}, nil +} diff --git a/business_message_builders_test.go b/business_message_builders_test.go new file mode 100644 index 000000000..ce9923797 --- /dev/null +++ b/business_message_builders_test.go @@ -0,0 +1,114 @@ +package whatsmeow + +import ( + "testing" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" +) + +func TestBuildBusinessProductMessageMatchesWebGenerator(t *testing.T) { + msg, err := BuildBusinessProductMessage(BusinessProductMessageParams{ + BusinessOwnerJID: types.NewJID("15550001", types.DefaultUserServer), + ProductID: "p-tea", Title: "Green tea", Description: "Twenty sachets", + CurrencyCode: "USD", PriceAmount1000: 1250, SalePriceAmount1000: 1100, + RetailerID: "sku-tea", URL: "https://synthetic.invalid/products/p-tea", + ProductImageCount: 1, ProductImage: &waE2E.ImageMessage{URL: testPtr("https://synthetic.invalid/media/tea")}, + Body: "Our most popular tea", Footer: "Seasonal catalog", + }) + if err != nil { + t.Fatal(err) + } + product := msg.GetProductMessage() + if product.GetBusinessOwnerJID() != "15550001@s.whatsapp.net" || product.GetBody() != "Our most popular tea" || product.GetFooter() != "Seasonal catalog" { + t.Fatalf("unexpected envelope: %#v", product) + } + snapshot := product.GetProduct() + if snapshot.GetProductID() != "p-tea" || snapshot.GetPriceAmount1000() != 1250 || snapshot.GetSalePriceAmount1000() != 1100 || snapshot.GetProductImage().GetURL() == "" { + t.Fatalf("unexpected product snapshot: %#v", snapshot) + } +} + +func TestBuildBusinessProductListMessageMatchesWebGenerator(t *testing.T) { + msg, err := BuildBusinessProductListMessage(BusinessProductListMessageParams{ + BusinessOwnerJID: types.NewJID("15550001", types.DefaultUserServer), + Title: "Seasonal", Description: "Choose a product", ButtonText: "View products", Footer: "Synthetic catalog", + Sections: []BusinessProductSection{{Title: "Tea", ProductIDs: []string{"p-tea", "p-mint"}}}, + }) + if err != nil { + t.Fatal(err) + } + list := msg.GetListMessage() + if list.GetListType() != waE2E.ListMessage_PRODUCT_LIST || list.GetProductListInfo().GetBusinessOwnerJID() != "15550001@s.whatsapp.net" { + t.Fatalf("unexpected list: %#v", list) + } + products := list.GetProductListInfo().GetProductSections()[0].GetProducts() + if len(products) != 2 || products[1].GetProductID() != "p-mint" { + t.Fatalf("unexpected products: %#v", products) + } +} + +func TestBuildBusinessOrderMessageMatchesWebGenerator(t *testing.T) { + msg, err := BuildBusinessOrderMessage(BusinessOrderMessageParams{ + OrderID: "o-100", ItemCount: 2, Status: waE2E.OrderMessage_INQUIRY, + Message: "Please review", OrderTitle: "Order o-100", + SellerJID: types.NewJID("15550001", types.DefaultUserServer), Token: "synthetic-token", + TotalAmount1000: 2650, TotalCurrencyCode: "USD", CatalogType: "regular", Thumbnail: []byte{1, 2, 3}, + }) + if err != nil { + t.Fatal(err) + } + order := msg.GetOrderMessage() + if order.GetOrderID() != "o-100" || order.GetSurface() != waE2E.OrderMessage_CATALOG || order.GetSellerJID() != "15550001@s.whatsapp.net" || order.GetTotalAmount1000() != 2650 { + t.Fatalf("unexpected order: %#v", order) + } +} + +func TestBuildBusinessListAndNativeFlowButtonsMatchWebGenerators(t *testing.T) { + list, err := BuildBusinessListMessage(BusinessListMessageParams{ + Title: "Support", Description: "Choose a topic", ButtonText: "View topics", Footer: "Synthetic support", + Sections: []BusinessListSection{{Title: "Account", Rows: []BusinessListRow{{ID: "billing", Title: "Billing", Description: "Invoices and plans"}}}}, + }) + if err != nil { + t.Fatal(err) + } + if list.GetListMessage().GetListType() != waE2E.ListMessage_SINGLE_SELECT || list.GetListMessage().GetSections()[0].GetRows()[0].GetRowID() != "billing" { + t.Fatalf("unexpected single-select list: %#v", list.GetListMessage()) + } + buttons, err := BuildBusinessNativeFlowButtonsMessage(BusinessNativeFlowButtonsMessageParams{ + Title: "Order help", Body: "Choose an action", Footer: "Synthetic support", + Buttons: []BusinessNativeFlowButton{{Name: "cta_url", ParamsJSON: `{"display_text":"Track order","url":"https://synthetic.invalid/order/o-100"}`}}, + }) + if err != nil { + t.Fatal(err) + } + button := buttons.GetButtonsMessage().GetButtons()[0] + if button.GetType() != waE2E.ButtonsMessage_Button_NATIVE_FLOW || button.GetNativeFlowInfo().GetName() != "cta_url" { + t.Fatalf("unexpected native-flow button: %#v", button) + } +} + +func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { + if _, err := BuildBusinessProductMessage(BusinessProductMessageParams{ProductID: "p", Title: "Tea", CurrencyCode: "USD"}); err == nil { + t.Fatal("expected missing owner to fail") + } + if _, err := BuildBusinessProductListMessage(BusinessProductListMessageParams{ + BusinessOwnerJID: types.NewJID("15550001", types.DefaultUserServer), Title: "Products", ButtonText: "View", + Sections: []BusinessProductSection{{Title: "Tea", ProductIDs: []string{"p", "p"}}}, + }); err == nil { + t.Fatal("expected duplicate product to fail") + } + if _, err := BuildBusinessOrderMessage(BusinessOrderMessageParams{ + OrderID: "o", ItemCount: 1, Status: waE2E.OrderMessage_INQUIRY, + SellerJID: types.NewJID("15550001", types.DefaultUserServer), TotalAmount1000: -1, TotalCurrencyCode: "USD", + }); err == nil { + t.Fatal("expected negative total to fail") + } + if _, err := BuildBusinessNativeFlowButtonsMessage(BusinessNativeFlowButtonsMessageParams{ + Body: "Choose", Buttons: []BusinessNativeFlowButton{{Name: "cta_url", ParamsJSON: "not-json"}}, + }); err == nil { + t.Fatal("expected malformed native-flow parameters to fail") + } +} + +func testPtr[T any](value T) *T { return &value } From 35a01b8b1aa156e59d34491c306233d37026b795 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 14:18:39 +0300 Subject: [PATCH 060/163] fix: preserve business message context --- business_message_builders.go | 23 ++++++++++++++--------- business_message_builders_test.go | 3 ++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 95422b8e2..9cc19e51b 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -25,6 +25,7 @@ type BusinessProductMessageParams struct { ProductImage *waE2E.ImageMessage Body string Footer string + ContextInfo *waE2E.ContextInfo } type BusinessProductSection struct { @@ -39,6 +40,7 @@ type BusinessProductListMessageParams struct { ButtonText string Footer string Sections []BusinessProductSection + ContextInfo *waE2E.ContextInfo } type BusinessOrderMessageParams struct { @@ -53,6 +55,7 @@ type BusinessOrderMessageParams struct { TotalAmount1000 int64 TotalCurrencyCode string CatalogType string + ContextInfo *waE2E.ContextInfo } type BusinessListRow struct { @@ -72,6 +75,7 @@ type BusinessListMessageParams struct { ButtonText string Footer string Sections []BusinessListSection + ContextInfo *waE2E.ContextInfo } type BusinessNativeFlowButton struct { @@ -80,10 +84,11 @@ type BusinessNativeFlowButton struct { } type BusinessNativeFlowButtonsMessageParams struct { - Title string - Body string - Footer string - Buttons []BusinessNativeFlowButton + Title string + Body string + Footer string + Buttons []BusinessNativeFlowButton + ContextInfo *waE2E.ContextInfo } func validBusinessOwner(jid types.JID) bool { @@ -147,7 +152,7 @@ func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Me PriceAmount1000: optionalPositiveInt64(params.PriceAmount1000), SalePriceAmount1000: optionalPositiveInt64(params.SalePriceAmount1000), RetailerID: optionalString(params.RetailerID), URL: optionalString(params.URL), ProductImageCount: optionalPositiveUint32(params.ProductImageCount), }, - BusinessOwnerJID: proto.String(params.BusinessOwnerJID.String()), Body: optionalString(params.Body), Footer: optionalString(params.Footer), + BusinessOwnerJID: proto.String(params.BusinessOwnerJID.String()), Body: optionalString(params.Body), Footer: optionalString(params.Footer), ContextInfo: params.ContextInfo, }}, nil } @@ -188,7 +193,7 @@ func BuildBusinessProductListMessage(params BusinessProductListMessageParams) (* return &waE2E.Message{ListMessage: &waE2E.ListMessage{ Title: proto.String(params.Title), Description: optionalString(params.Description), ButtonText: proto.String(params.ButtonText), ListType: waE2E.ListMessage_PRODUCT_LIST.Enum(), FooterText: optionalString(params.Footer), - ProductListInfo: &waE2E.ListMessage_ProductListInfo{ProductSections: sections, BusinessOwnerJID: proto.String(params.BusinessOwnerJID.String())}, + ProductListInfo: &waE2E.ListMessage_ProductListInfo{ProductSections: sections, BusinessOwnerJID: proto.String(params.BusinessOwnerJID.String())}, ContextInfo: params.ContextInfo, }}, nil } @@ -209,7 +214,7 @@ func BuildBusinessOrderMessage(params BusinessOrderMessageParams) (*waE2E.Messag OrderID: proto.String(params.OrderID), Thumbnail: params.Thumbnail, ItemCount: proto.Int32(params.ItemCount), Status: params.Status.Enum(), Surface: waE2E.OrderMessage_CATALOG.Enum(), Message: optionalString(params.Message), OrderTitle: optionalString(params.OrderTitle), SellerJID: proto.String(params.SellerJID.String()), Token: optionalString(params.Token), - TotalAmount1000: proto.Int64(params.TotalAmount1000), TotalCurrencyCode: proto.String(params.TotalCurrencyCode), CatalogType: optionalString(params.CatalogType), + TotalAmount1000: proto.Int64(params.TotalAmount1000), TotalCurrencyCode: proto.String(params.TotalCurrencyCode), CatalogType: optionalString(params.CatalogType), ContextInfo: params.ContextInfo, }}, nil } @@ -246,7 +251,7 @@ func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, } return &waE2E.Message{ListMessage: &waE2E.ListMessage{ Title: proto.String(params.Title), Description: optionalString(params.Description), ButtonText: proto.String(params.ButtonText), - ListType: waE2E.ListMessage_SINGLE_SELECT.Enum(), Sections: sections, FooterText: optionalString(params.Footer), + ListType: waE2E.ListMessage_SINGLE_SELECT.Enum(), Sections: sections, FooterText: optionalString(params.Footer), ContextInfo: params.ContextInfo, }}, nil } @@ -275,7 +280,7 @@ func BuildBusinessNativeFlowButtonsMessage(params BusinessNativeFlowButtonsMessa } headerType := waE2E.ButtonsMessage_EMPTY message := &waE2E.ButtonsMessage{ - ContentText: proto.String(params.Body), FooterText: optionalString(params.Footer), Buttons: buttons, HeaderType: headerType.Enum(), + ContentText: proto.String(params.Body), FooterText: optionalString(params.Footer), Buttons: buttons, HeaderType: headerType.Enum(), ContextInfo: params.ContextInfo, } if params.Title != "" { headerType = waE2E.ButtonsMessage_TEXT diff --git a/business_message_builders_test.go b/business_message_builders_test.go index ce9923797..f950c823f 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -15,12 +15,13 @@ func TestBuildBusinessProductMessageMatchesWebGenerator(t *testing.T) { RetailerID: "sku-tea", URL: "https://synthetic.invalid/products/p-tea", ProductImageCount: 1, ProductImage: &waE2E.ImageMessage{URL: testPtr("https://synthetic.invalid/media/tea")}, Body: "Our most popular tea", Footer: "Seasonal catalog", + ContextInfo: &waE2E.ContextInfo{MentionedJID: []string{"15550002@s.whatsapp.net"}}, }) if err != nil { t.Fatal(err) } product := msg.GetProductMessage() - if product.GetBusinessOwnerJID() != "15550001@s.whatsapp.net" || product.GetBody() != "Our most popular tea" || product.GetFooter() != "Seasonal catalog" { + if product.GetBusinessOwnerJID() != "15550001@s.whatsapp.net" || product.GetBody() != "Our most popular tea" || product.GetFooter() != "Seasonal catalog" || len(product.GetContextInfo().GetMentionedJID()) != 1 { t.Fatalf("unexpected envelope: %#v", product) } snapshot := product.GetProduct() From 532b56ff864f8f484c0cbc127c786632151c751c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 00:42:39 +0300 Subject: [PATCH 061/163] build: format business message imports --- business_message_builders.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/business_message_builders.go b/business_message_builders.go index 9cc19e51b..7cb8eab2f 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -6,9 +6,10 @@ import ( "fmt" "strings" + "google.golang.org/protobuf/proto" + "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" - "google.golang.org/protobuf/proto" ) type BusinessProductMessageParams struct { From cf3c3158a8c376337ab4fb5c5aeef462ccdb228a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:02:14 +0300 Subject: [PATCH 062/163] fix: validate business message builders --- business_message_builders.go | 14 ++++----- business_message_builders_test.go | 49 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 7cb8eab2f..373068f3e 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -93,7 +93,7 @@ type BusinessNativeFlowButtonsMessageParams struct { } func validBusinessOwner(jid types.JID) bool { - return !jid.IsEmpty() && jid.Server == types.DefaultUserServer && jid.Device == 0 + return !jid.IsEmpty() && (jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer) } func validCurrency(code string) bool { @@ -153,7 +153,7 @@ func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Me PriceAmount1000: optionalPositiveInt64(params.PriceAmount1000), SalePriceAmount1000: optionalPositiveInt64(params.SalePriceAmount1000), RetailerID: optionalString(params.RetailerID), URL: optionalString(params.URL), ProductImageCount: optionalPositiveUint32(params.ProductImageCount), }, - BusinessOwnerJID: proto.String(params.BusinessOwnerJID.String()), Body: optionalString(params.Body), Footer: optionalString(params.Footer), ContextInfo: params.ContextInfo, + BusinessOwnerJID: proto.String(params.BusinessOwnerJID.ToNonAD().String()), Body: optionalString(params.Body), Footer: optionalString(params.Footer), ContextInfo: params.ContextInfo, }}, nil } @@ -194,7 +194,7 @@ func BuildBusinessProductListMessage(params BusinessProductListMessageParams) (* return &waE2E.Message{ListMessage: &waE2E.ListMessage{ Title: proto.String(params.Title), Description: optionalString(params.Description), ButtonText: proto.String(params.ButtonText), ListType: waE2E.ListMessage_PRODUCT_LIST.Enum(), FooterText: optionalString(params.Footer), - ProductListInfo: &waE2E.ListMessage_ProductListInfo{ProductSections: sections, BusinessOwnerJID: proto.String(params.BusinessOwnerJID.String())}, ContextInfo: params.ContextInfo, + ProductListInfo: &waE2E.ListMessage_ProductListInfo{ProductSections: sections, BusinessOwnerJID: proto.String(params.BusinessOwnerJID.ToNonAD().String())}, ContextInfo: params.ContextInfo, }}, nil } @@ -214,13 +214,13 @@ func BuildBusinessOrderMessage(params BusinessOrderMessageParams) (*waE2E.Messag return &waE2E.Message{OrderMessage: &waE2E.OrderMessage{ OrderID: proto.String(params.OrderID), Thumbnail: params.Thumbnail, ItemCount: proto.Int32(params.ItemCount), Status: params.Status.Enum(), Surface: waE2E.OrderMessage_CATALOG.Enum(), Message: optionalString(params.Message), - OrderTitle: optionalString(params.OrderTitle), SellerJID: proto.String(params.SellerJID.String()), Token: optionalString(params.Token), + OrderTitle: optionalString(params.OrderTitle), SellerJID: proto.String(params.SellerJID.ToNonAD().String()), Token: optionalString(params.Token), TotalAmount1000: proto.Int64(params.TotalAmount1000), TotalCurrencyCode: proto.String(params.TotalCurrencyCode), CatalogType: optionalString(params.CatalogType), ContextInfo: params.ContextInfo, }}, nil } func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, error) { - if strings.TrimSpace(params.Title) == "" || !bounded(params.Title, 256) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 64) || !bounded(params.Description, 4096) || !bounded(params.Footer, 256) { + if !bounded(params.Title, 256) || strings.TrimSpace(params.Description) == "" || !bounded(params.Description, 4096) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 64) || !bounded(params.Footer, 256) { return nil, errors.New("invalid business list text") } if len(params.Sections) == 0 || len(params.Sections) > 10 { @@ -247,8 +247,8 @@ func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, } sections[sectionIndex] = &waE2E.ListMessage_Section{Title: optionalString(section.Title), Rows: rows} } - if rowCount > 30 { - return nil, errors.New("business list exceeds 30 rows") + if rowCount > 10 { + return nil, errors.New("business list exceeds 10 rows") } return &waE2E.Message{ListMessage: &waE2E.ListMessage{ Title: proto.String(params.Title), Description: optionalString(params.Description), ButtonText: proto.String(params.ButtonText), diff --git a/business_message_builders_test.go b/business_message_builders_test.go index f950c823f..b68ae792d 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -1,6 +1,7 @@ package whatsmeow import ( + "fmt" "testing" "go.mau.fi/whatsmeow/proto/waE2E" @@ -89,6 +90,54 @@ func TestBuildBusinessListAndNativeFlowButtonsMatchWebGenerators(t *testing.T) { } } +func TestBusinessMessageBuildersNormalizeOwnerJIDs(t *testing.T) { + deviceOwner := types.NewADJID("15550001", 0, 3) + product, err := BuildBusinessProductMessage(BusinessProductMessageParams{ + BusinessOwnerJID: deviceOwner, ProductID: "p-tea", Title: "Tea", CurrencyCode: "USD", + }) + if err != nil { + t.Fatal(err) + } + if got := product.GetProductMessage().GetBusinessOwnerJID(); got != deviceOwner.ToNonAD().String() { + t.Fatalf("product owner = %q", got) + } + lidOwner := types.NewJID("123456789", types.HiddenUserServer) + list, err := BuildBusinessProductListMessage(BusinessProductListMessageParams{ + BusinessOwnerJID: lidOwner, Title: "Products", ButtonText: "View", + Sections: []BusinessProductSection{{ProductIDs: []string{"p-tea"}}}, + }) + if err != nil { + t.Fatal(err) + } + if got := list.GetListMessage().GetProductListInfo().GetBusinessOwnerJID(); got != lidOwner.String() { + t.Fatalf("product list owner = %q", got) + } +} + +func TestBuildBusinessListRequiresBodyAndCapsRows(t *testing.T) { + valid := BusinessListMessageParams{ + Description: "Choose a topic", ButtonText: "View topics", + Sections: []BusinessListSection{{Rows: []BusinessListRow{{ID: "one", Title: "One"}}}}, + } + if _, err := BuildBusinessListMessage(valid); err != nil { + t.Fatalf("headerless list failed: %v", err) + } + missingBody := valid + missingBody.Title = "Optional header" + missingBody.Description = "" + if _, err := BuildBusinessListMessage(missingBody); err == nil { + t.Fatal("list without a body unexpectedly passed") + } + tooManyRows := valid + tooManyRows.Sections[0].Rows = make([]BusinessListRow, 11) + for index := range tooManyRows.Sections[0].Rows { + tooManyRows.Sections[0].Rows[index] = BusinessListRow{ID: fmt.Sprintf("row-%d", index), Title: "Row"} + } + if _, err := BuildBusinessListMessage(tooManyRows); err == nil { + t.Fatal("list with more than ten rows unexpectedly passed") + } +} + func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { if _, err := BuildBusinessProductMessage(BusinessProductMessageParams{ProductID: "p", Title: "Tea", CurrencyCode: "USD"}); err == nil { t.Fatal("expected missing owner to fail") From 8280d4db94dfdbdceb3d645d020c03f47bd1ba31 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:30:30 +0300 Subject: [PATCH 063/163] fix: require business order tokens --- business_message_builders.go | 2 +- business_message_builders_test.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/business_message_builders.go b/business_message_builders.go index 373068f3e..8e5d98db4 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -202,7 +202,7 @@ func BuildBusinessOrderMessage(params BusinessOrderMessageParams) (*waE2E.Messag if !validBusinessOwner(params.SellerJID) { return nil, errors.New("invalid seller JID") } - if strings.TrimSpace(params.OrderID) == "" || !bounded(params.OrderID, 256) || params.ItemCount < 1 || params.ItemCount > 100 { + if strings.TrimSpace(params.OrderID) == "" || !bounded(params.OrderID, 256) || strings.TrimSpace(params.Token) == "" || params.ItemCount < 1 || params.ItemCount > 100 { return nil, errors.New("invalid business order identity") } if params.Status < waE2E.OrderMessage_INQUIRY || params.Status > waE2E.OrderMessage_DECLINED || params.TotalAmount1000 < 0 || !validCurrency(params.TotalCurrencyCode) { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index b68ae792d..c6c426e5f 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -154,6 +154,12 @@ func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { }); err == nil { t.Fatal("expected negative total to fail") } + if _, err := BuildBusinessOrderMessage(BusinessOrderMessageParams{ + OrderID: "o", ItemCount: 1, Status: waE2E.OrderMessage_INQUIRY, + SellerJID: types.NewJID("15550001", types.DefaultUserServer), Token: " ", TotalCurrencyCode: "USD", + }); err == nil { + t.Fatal("expected blank order token to fail") + } if _, err := BuildBusinessNativeFlowButtonsMessage(BusinessNativeFlowButtonsMessageParams{ Body: "Choose", Buttons: []BusinessNativeFlowButton{{Name: "cta_url", ParamsJSON: "not-json"}}, }); err == nil { From b0cbfcf37ee35ef9b41afea3bbfb65db080c38c2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:22:25 +0300 Subject: [PATCH 064/163] fix: enforce business message limits --- business_message_builders.go | 19 +++++++++++--- business_message_builders_test.go | 41 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 8e5d98db4..17ae4a319 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "strings" "google.golang.org/protobuf/proto" @@ -146,6 +147,18 @@ func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Me if !validCurrency(params.CurrencyCode) || params.PriceAmount1000 < 0 || params.SalePriceAmount1000 < 0 { return nil, errors.New("invalid business product price") } + if params.SalePriceAmount1000 > 0 && params.PriceAmount1000 == 0 { + return nil, errors.New("business product sale price requires a base price") + } + if params.ProductImageCount > 10 { + return nil, errors.New("business product cannot contain more than 10 images") + } + if params.URL != "" { + parsed, err := url.ParseRequestURI(params.URL) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" { + return nil, errors.New("business product URL must be absolute HTTPS") + } + } return &waE2E.Message{ProductMessage: &waE2E.ProductMessage{ Product: &waE2E.ProductMessage_ProductSnapshot{ ProductImage: params.ProductImage, ProductID: proto.String(params.ProductID), Title: proto.String(params.Title), @@ -220,7 +233,7 @@ func BuildBusinessOrderMessage(params BusinessOrderMessageParams) (*waE2E.Messag } func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, error) { - if !bounded(params.Title, 256) || strings.TrimSpace(params.Description) == "" || !bounded(params.Description, 4096) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 64) || !bounded(params.Footer, 256) { + if !bounded(params.Title, 60) || strings.TrimSpace(params.Description) == "" || !bounded(params.Description, 1024) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { return nil, errors.New("invalid business list text") } if len(params.Sections) == 0 || len(params.Sections) > 10 { @@ -230,12 +243,12 @@ func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, seen := make(map[string]struct{}) rowCount := 0 for sectionIndex, section := range params.Sections { - if !bounded(section.Title, 256) || len(section.Rows) == 0 { + if !bounded(section.Title, 24) || len(section.Rows) == 0 { return nil, fmt.Errorf("invalid business list section %d", sectionIndex) } rows := make([]*waE2E.ListMessage_Row, len(section.Rows)) for rowIndex, row := range section.Rows { - if strings.TrimSpace(row.ID) == "" || !bounded(row.ID, 256) || strings.TrimSpace(row.Title) == "" || !bounded(row.Title, 256) || !bounded(row.Description, 1024) { + if strings.TrimSpace(row.ID) == "" || !bounded(row.ID, 256) || strings.TrimSpace(row.Title) == "" || !bounded(row.Title, 24) || !bounded(row.Description, 72) { return nil, fmt.Errorf("invalid business list row %d in section %d", rowIndex, sectionIndex) } if _, exists := seen[row.ID]; exists { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index c6c426e5f..2b57a9752 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -2,6 +2,7 @@ package whatsmeow import ( "fmt" + "strings" "testing" "go.mau.fi/whatsmeow/proto/waE2E" @@ -142,6 +143,18 @@ func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { if _, err := BuildBusinessProductMessage(BusinessProductMessageParams{ProductID: "p", Title: "Tea", CurrencyCode: "USD"}); err == nil { t.Fatal("expected missing owner to fail") } + owner := types.NewJID("15550001", types.DefaultUserServer) + for name, params := range map[string]BusinessProductMessageParams{ + "non-HTTPS URL": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", CurrencyCode: "USD", URL: "http://synthetic.invalid/product"}, + "sale without price": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", CurrencyCode: "USD", SalePriceAmount1000: 1000}, + "too many images": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", CurrencyCode: "USD", ProductImageCount: 11}, + } { + t.Run(name, func(t *testing.T) { + if _, err := BuildBusinessProductMessage(params); err == nil { + t.Fatal("expected product validation error") + } + }) + } if _, err := BuildBusinessProductListMessage(BusinessProductListMessageParams{ BusinessOwnerJID: types.NewJID("15550001", types.DefaultUserServer), Title: "Products", ButtonText: "View", Sections: []BusinessProductSection{{Title: "Tea", ProductIDs: []string{"p", "p"}}}, @@ -167,4 +180,32 @@ func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { } } +func TestBusinessListMessageEnforcesProtocolTextLimits(t *testing.T) { + valid := BusinessListMessageParams{ + Title: "Menu", Description: "Choose one", ButtonText: "Choose", Footer: "Footer", + Sections: []BusinessListSection{{Title: "Section", Rows: []BusinessListRow{{ID: "one", Title: "One", Description: "Description"}}}}, + } + mutations := map[string]func(*BusinessListMessageParams){ + "header": func(params *BusinessListMessageParams) { params.Title = strings.Repeat("h", 61) }, + "body": func(params *BusinessListMessageParams) { params.Description = strings.Repeat("b", 1025) }, + "button": func(params *BusinessListMessageParams) { params.ButtonText = strings.Repeat("c", 21) }, + "footer": func(params *BusinessListMessageParams) { params.Footer = strings.Repeat("f", 61) }, + "section title": func(params *BusinessListMessageParams) { params.Sections[0].Title = strings.Repeat("s", 25) }, + "row title": func(params *BusinessListMessageParams) { params.Sections[0].Rows[0].Title = strings.Repeat("r", 25) }, + "row description": func(params *BusinessListMessageParams) { + params.Sections[0].Rows[0].Description = strings.Repeat("d", 73) + }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + params := valid + params.Sections = []BusinessListSection{{Title: valid.Sections[0].Title, Rows: append([]BusinessListRow(nil), valid.Sections[0].Rows...)}} + mutate(¶ms) + if _, err := BuildBusinessListMessage(params); err == nil { + t.Fatal("expected protocol limit error") + } + }) + } +} + func testPtr[T any](value T) *T { return &value } From 74ec8eb3f789b633222c78e705148109208cf09b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:32:12 +0300 Subject: [PATCH 065/163] fix: align business builder contracts --- business_message_builders.go | 20 ++++++----- business_message_builders_test.go | 59 +++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 17ae4a319..03ec953ea 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -94,7 +94,7 @@ type BusinessNativeFlowButtonsMessageParams struct { } func validBusinessOwner(jid types.JID) bool { - return !jid.IsEmpty() && (jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer) + return !jid.IsEmpty() && jid.User != "" && (jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer) } func validCurrency(code string) bool { @@ -144,11 +144,15 @@ func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Me if !bounded(params.Description, 4096) || !bounded(params.RetailerID, 256) || !bounded(params.URL, 2048) || !bounded(params.Body, 4096) || !bounded(params.Footer, 256) { return nil, errors.New("business product message field is too large") } - if !validCurrency(params.CurrencyCode) || params.PriceAmount1000 < 0 || params.SalePriceAmount1000 < 0 { + if params.PriceAmount1000 < 0 || params.SalePriceAmount1000 < 0 { return nil, errors.New("invalid business product price") } - if params.SalePriceAmount1000 > 0 && params.PriceAmount1000 == 0 { - return nil, errors.New("business product sale price requires a base price") + if params.PriceAmount1000 == 0 { + if params.CurrencyCode != "" || params.SalePriceAmount1000 > 0 { + return nil, errors.New("business product currency and sale price require a base price") + } + } else if !validCurrency(params.CurrencyCode) { + return nil, errors.New("invalid business product currency") } if params.ProductImageCount > 10 { return nil, errors.New("business product cannot contain more than 10 images") @@ -162,7 +166,7 @@ func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Me return &waE2E.Message{ProductMessage: &waE2E.ProductMessage{ Product: &waE2E.ProductMessage_ProductSnapshot{ ProductImage: params.ProductImage, ProductID: proto.String(params.ProductID), Title: proto.String(params.Title), - Description: optionalString(params.Description), CurrencyCode: proto.String(params.CurrencyCode), + Description: optionalString(params.Description), CurrencyCode: optionalString(params.CurrencyCode), PriceAmount1000: optionalPositiveInt64(params.PriceAmount1000), SalePriceAmount1000: optionalPositiveInt64(params.SalePriceAmount1000), RetailerID: optionalString(params.RetailerID), URL: optionalString(params.URL), ProductImageCount: optionalPositiveUint32(params.ProductImageCount), }, @@ -174,7 +178,7 @@ func BuildBusinessProductListMessage(params BusinessProductListMessageParams) (* if !validBusinessOwner(params.BusinessOwnerJID) { return nil, errors.New("invalid business owner JID") } - if strings.TrimSpace(params.Title) == "" || !bounded(params.Title, 256) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 64) || !bounded(params.Description, 4096) || !bounded(params.Footer, 256) { + if strings.TrimSpace(params.Title) == "" || !bounded(params.Title, 60) || strings.TrimSpace(params.Description) == "" || !bounded(params.Description, 1024) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { return nil, errors.New("invalid business product list text") } if len(params.Sections) == 0 || len(params.Sections) > 10 { @@ -184,7 +188,7 @@ func BuildBusinessProductListMessage(params BusinessProductListMessageParams) (* seen := make(map[string]struct{}) productCount := 0 for index, section := range params.Sections { - if !bounded(section.Title, 256) || len(section.ProductIDs) == 0 { + if !bounded(section.Title, 24) || len(section.ProductIDs) == 0 { return nil, fmt.Errorf("invalid business product section %d", index) } products := make([]*waE2E.ListMessage_Product, len(section.ProductIDs)) @@ -270,7 +274,7 @@ func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, } func BuildBusinessNativeFlowButtonsMessage(params BusinessNativeFlowButtonsMessageParams) (*waE2E.Message, error) { - if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 4096) || !bounded(params.Title, 256) || !bounded(params.Footer, 256) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 1024) || !bounded(params.Title, 60) || !bounded(params.Footer, 60) { return nil, errors.New("invalid business native-flow text") } if len(params.Buttons) == 0 || len(params.Buttons) > 3 { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index 2b57a9752..a108e950d 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -94,7 +94,7 @@ func TestBuildBusinessListAndNativeFlowButtonsMatchWebGenerators(t *testing.T) { func TestBusinessMessageBuildersNormalizeOwnerJIDs(t *testing.T) { deviceOwner := types.NewADJID("15550001", 0, 3) product, err := BuildBusinessProductMessage(BusinessProductMessageParams{ - BusinessOwnerJID: deviceOwner, ProductID: "p-tea", Title: "Tea", CurrencyCode: "USD", + BusinessOwnerJID: deviceOwner, ProductID: "p-tea", Title: "Tea", CurrencyCode: "USD", PriceAmount1000: 1250, }) if err != nil { t.Fatal(err) @@ -104,7 +104,7 @@ func TestBusinessMessageBuildersNormalizeOwnerJIDs(t *testing.T) { } lidOwner := types.NewJID("123456789", types.HiddenUserServer) list, err := BuildBusinessProductListMessage(BusinessProductListMessageParams{ - BusinessOwnerJID: lidOwner, Title: "Products", ButtonText: "View", + BusinessOwnerJID: lidOwner, Title: "Products", Description: "Choose a product", ButtonText: "View", Sections: []BusinessProductSection{{ProductIDs: []string{"p-tea"}}}, }) if err != nil { @@ -155,6 +155,16 @@ func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { } }) } + if _, err := BuildBusinessProductMessage(BusinessProductMessageParams{ + BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", + }); err != nil { + t.Fatalf("unpriced product was rejected: %v", err) + } + if _, err := BuildBusinessProductMessage(BusinessProductMessageParams{ + BusinessOwnerJID: types.NewJID("", types.DefaultUserServer), ProductID: "p", Title: "Tea", + }); err == nil { + t.Fatal("expected ownerless business JID to fail") + } if _, err := BuildBusinessProductListMessage(BusinessProductListMessageParams{ BusinessOwnerJID: types.NewJID("15550001", types.DefaultUserServer), Title: "Products", ButtonText: "View", Sections: []BusinessProductSection{{Title: "Tea", ProductIDs: []string{"p", "p"}}}, @@ -180,6 +190,51 @@ func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { } } +func TestBusinessProductListAndNativeFlowTextLimits(t *testing.T) { + owner := types.NewJID("15550001", types.DefaultUserServer) + productList := BusinessProductListMessageParams{ + BusinessOwnerJID: owner, Title: "Products", Description: "Choose", ButtonText: "View", Footer: "Footer", + Sections: []BusinessProductSection{{Title: "Section", ProductIDs: []string{"p"}}}, + } + productMutations := map[string]func(*BusinessProductListMessageParams){ + "missing body": func(params *BusinessProductListMessageParams) { params.Description = "" }, + "header": func(params *BusinessProductListMessageParams) { params.Title = strings.Repeat("h", 61) }, + "body": func(params *BusinessProductListMessageParams) { params.Description = strings.Repeat("b", 1025) }, + "button": func(params *BusinessProductListMessageParams) { params.ButtonText = strings.Repeat("c", 21) }, + "footer": func(params *BusinessProductListMessageParams) { params.Footer = strings.Repeat("f", 61) }, + "section title": func(params *BusinessProductListMessageParams) { params.Sections[0].Title = strings.Repeat("s", 25) }, + } + for name, mutate := range productMutations { + t.Run("product list "+name, func(t *testing.T) { + params := productList + params.Sections = append([]BusinessProductSection(nil), productList.Sections...) + mutate(¶ms) + if _, err := BuildBusinessProductListMessage(params); err == nil { + t.Fatal("expected product-list protocol limit error") + } + }) + } + + nativeFlow := BusinessNativeFlowButtonsMessageParams{ + Title: "Title", Body: "Choose", Footer: "Footer", + Buttons: []BusinessNativeFlowButton{{Name: "cta_url", ParamsJSON: `{}`}}, + } + nativeMutations := map[string]func(*BusinessNativeFlowButtonsMessageParams){ + "header": func(params *BusinessNativeFlowButtonsMessageParams) { params.Title = strings.Repeat("h", 61) }, + "body": func(params *BusinessNativeFlowButtonsMessageParams) { params.Body = strings.Repeat("b", 1025) }, + "footer": func(params *BusinessNativeFlowButtonsMessageParams) { params.Footer = strings.Repeat("f", 61) }, + } + for name, mutate := range nativeMutations { + t.Run("native flow "+name, func(t *testing.T) { + params := nativeFlow + mutate(¶ms) + if _, err := BuildBusinessNativeFlowButtonsMessage(params); err == nil { + t.Fatal("expected native-flow protocol limit error") + } + }) + } +} + func TestBusinessListMessageEnforcesProtocolTextLimits(t *testing.T) { valid := BusinessListMessageParams{ Title: "Menu", Description: "Choose one", ButtonText: "Choose", Footer: "Footer", From 60e74f9332312b6d60bb6ca488806ee7882f7a9e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:43:06 +0300 Subject: [PATCH 066/163] fix: preserve explicit zero product prices --- business_message_builders.go | 16 ++++++++++------ business_message_builders_test.go | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 03ec953ea..97d048521 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -147,11 +147,11 @@ func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Me if params.PriceAmount1000 < 0 || params.SalePriceAmount1000 < 0 { return nil, errors.New("invalid business product price") } - if params.PriceAmount1000 == 0 { - if params.CurrencyCode != "" || params.SalePriceAmount1000 > 0 { - return nil, errors.New("business product currency and sale price require a base price") - } - } else if !validCurrency(params.CurrencyCode) { + pricePresent := params.PriceAmount1000 != 0 || params.CurrencyCode != "" + if !pricePresent && params.SalePriceAmount1000 > 0 { + return nil, errors.New("business product sale price requires a base price") + } + if pricePresent && !validCurrency(params.CurrencyCode) { return nil, errors.New("invalid business product currency") } if params.ProductImageCount > 10 { @@ -163,11 +163,15 @@ func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Me return nil, errors.New("business product URL must be absolute HTTPS") } } + priceAmount1000 := optionalPositiveInt64(params.PriceAmount1000) + if pricePresent { + priceAmount1000 = proto.Int64(params.PriceAmount1000) + } return &waE2E.Message{ProductMessage: &waE2E.ProductMessage{ Product: &waE2E.ProductMessage_ProductSnapshot{ ProductImage: params.ProductImage, ProductID: proto.String(params.ProductID), Title: proto.String(params.Title), Description: optionalString(params.Description), CurrencyCode: optionalString(params.CurrencyCode), - PriceAmount1000: optionalPositiveInt64(params.PriceAmount1000), SalePriceAmount1000: optionalPositiveInt64(params.SalePriceAmount1000), + PriceAmount1000: priceAmount1000, SalePriceAmount1000: optionalPositiveInt64(params.SalePriceAmount1000), RetailerID: optionalString(params.RetailerID), URL: optionalString(params.URL), ProductImageCount: optionalPositiveUint32(params.ProductImageCount), }, BusinessOwnerJID: proto.String(params.BusinessOwnerJID.ToNonAD().String()), Body: optionalString(params.Body), Footer: optionalString(params.Footer), ContextInfo: params.ContextInfo, diff --git a/business_message_builders_test.go b/business_message_builders_test.go index a108e950d..f9156ef1c 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -32,6 +32,23 @@ func TestBuildBusinessProductMessageMatchesWebGenerator(t *testing.T) { } } +func TestBuildBusinessProductMessagePreservesExplicitZeroPrice(t *testing.T) { + msg, err := BuildBusinessProductMessage(BusinessProductMessageParams{ + BusinessOwnerJID: types.NewJID("15550001", types.DefaultUserServer), + ProductID: "p-free", + Title: "Free sample", + CurrencyCode: "USD", + PriceAmount1000: 0, + }) + if err != nil { + t.Fatal(err) + } + price := msg.GetProductMessage().GetProduct().PriceAmount1000 + if price == nil || *price != 0 { + t.Fatalf("explicit zero price was not preserved: %#v", price) + } +} + func TestBuildBusinessProductListMessageMatchesWebGenerator(t *testing.T) { msg, err := BuildBusinessProductListMessage(BusinessProductListMessageParams{ BusinessOwnerJID: types.NewJID("15550001", types.DefaultUserServer), @@ -146,7 +163,7 @@ func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { owner := types.NewJID("15550001", types.DefaultUserServer) for name, params := range map[string]BusinessProductMessageParams{ "non-HTTPS URL": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", CurrencyCode: "USD", URL: "http://synthetic.invalid/product"}, - "sale without price": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", CurrencyCode: "USD", SalePriceAmount1000: 1000}, + "sale without price": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", SalePriceAmount1000: 1000}, "too many images": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", CurrencyCode: "USD", ProductImageCount: 11}, } { t.Run(name, func(t *testing.T) { From 04bf3a646da19e4cb50af1bc25e49deb38987da5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:02:08 +0300 Subject: [PATCH 067/163] fix: enforce single-select list limits --- business_message_builders.go | 4 ++-- business_message_builders_test.go | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 97d048521..001405582 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -251,12 +251,12 @@ func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, seen := make(map[string]struct{}) rowCount := 0 for sectionIndex, section := range params.Sections { - if !bounded(section.Title, 24) || len(section.Rows) == 0 { + if !bounded(section.Title, 24) || len(section.Rows) == 0 || (len(params.Sections) > 1 && strings.TrimSpace(section.Title) == "") { return nil, fmt.Errorf("invalid business list section %d", sectionIndex) } rows := make([]*waE2E.ListMessage_Row, len(section.Rows)) for rowIndex, row := range section.Rows { - if strings.TrimSpace(row.ID) == "" || !bounded(row.ID, 256) || strings.TrimSpace(row.Title) == "" || !bounded(row.Title, 24) || !bounded(row.Description, 72) { + if strings.TrimSpace(row.ID) == "" || !bounded(row.ID, 200) || strings.TrimSpace(row.Title) == "" || !bounded(row.Title, 24) || !bounded(row.Description, 72) { return nil, fmt.Errorf("invalid business list row %d in section %d", rowIndex, sectionIndex) } if _, exists := seen[row.ID]; exists { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index f9156ef1c..f4ab4e608 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -263,6 +263,7 @@ func TestBusinessListMessageEnforcesProtocolTextLimits(t *testing.T) { "button": func(params *BusinessListMessageParams) { params.ButtonText = strings.Repeat("c", 21) }, "footer": func(params *BusinessListMessageParams) { params.Footer = strings.Repeat("f", 61) }, "section title": func(params *BusinessListMessageParams) { params.Sections[0].Title = strings.Repeat("s", 25) }, + "row ID": func(params *BusinessListMessageParams) { params.Sections[0].Rows[0].ID = strings.Repeat("i", 201) }, "row title": func(params *BusinessListMessageParams) { params.Sections[0].Rows[0].Title = strings.Repeat("r", 25) }, "row description": func(params *BusinessListMessageParams) { params.Sections[0].Rows[0].Description = strings.Repeat("d", 73) @@ -278,6 +279,14 @@ func TestBusinessListMessageEnforcesProtocolTextLimits(t *testing.T) { } }) } + multipleSections := valid + multipleSections.Sections = []BusinessListSection{ + {Rows: []BusinessListRow{{ID: "one", Title: "One"}}}, + {Title: "Second", Rows: []BusinessListRow{{ID: "two", Title: "Two"}}}, + } + if _, err := BuildBusinessListMessage(multipleSections); err == nil { + t.Fatal("multiple sections with an empty title unexpectedly passed") + } } func testPtr[T any](value T) *T { return &value } From 6a826c983db88012b717e90ef47a378e48e98e3a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:15:43 +0300 Subject: [PATCH 068/163] fix: require product section titles --- business_message_builders.go | 2 +- business_message_builders_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/business_message_builders.go b/business_message_builders.go index 001405582..be0824d07 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -192,7 +192,7 @@ func BuildBusinessProductListMessage(params BusinessProductListMessageParams) (* seen := make(map[string]struct{}) productCount := 0 for index, section := range params.Sections { - if !bounded(section.Title, 24) || len(section.ProductIDs) == 0 { + if !bounded(section.Title, 24) || len(section.ProductIDs) == 0 || (len(params.Sections) > 1 && strings.TrimSpace(section.Title) == "") { return nil, fmt.Errorf("invalid business product section %d", index) } products := make([]*waE2E.ListMessage_Product, len(section.ProductIDs)) diff --git a/business_message_builders_test.go b/business_message_builders_test.go index f4ab4e608..f06a1ab11 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -231,6 +231,14 @@ func TestBusinessProductListAndNativeFlowTextLimits(t *testing.T) { } }) } + multipleProductSections := productList + multipleProductSections.Sections = []BusinessProductSection{ + {ProductIDs: []string{"one"}}, + {Title: "Second", ProductIDs: []string{"two"}}, + } + if _, err := BuildBusinessProductListMessage(multipleProductSections); err == nil { + t.Fatal("multiple product sections with an empty title unexpectedly passed") + } nativeFlow := BusinessNativeFlowButtonsMessageParams{ Title: "Title", Body: "Choose", Footer: "Footer", From db7514ee1507d8f567e0fa4d8284403bbd1e7f8c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 08:06:46 +0300 Subject: [PATCH 069/163] fix: preserve optional business fields --- business_message_builders.go | 4 ++-- business_message_builders_test.go | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index be0824d07..7b3ce0842 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -182,7 +182,7 @@ func BuildBusinessProductListMessage(params BusinessProductListMessageParams) (* if !validBusinessOwner(params.BusinessOwnerJID) { return nil, errors.New("invalid business owner JID") } - if strings.TrimSpace(params.Title) == "" || !bounded(params.Title, 60) || strings.TrimSpace(params.Description) == "" || !bounded(params.Description, 1024) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { + if strings.TrimSpace(params.Title) == "" || !bounded(params.Title, 60) || !bounded(params.Description, 1024) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { return nil, errors.New("invalid business product list text") } if len(params.Sections) == 0 || len(params.Sections) > 10 { @@ -223,7 +223,7 @@ func BuildBusinessOrderMessage(params BusinessOrderMessageParams) (*waE2E.Messag if !validBusinessOwner(params.SellerJID) { return nil, errors.New("invalid seller JID") } - if strings.TrimSpace(params.OrderID) == "" || !bounded(params.OrderID, 256) || strings.TrimSpace(params.Token) == "" || params.ItemCount < 1 || params.ItemCount > 100 { + if strings.TrimSpace(params.OrderID) == "" || !bounded(params.OrderID, 256) || (params.Token != "" && strings.TrimSpace(params.Token) == "") || params.ItemCount < 1 || params.ItemCount > 100 { return nil, errors.New("invalid business order identity") } if params.Status < waE2E.OrderMessage_INQUIRY || params.Status > waE2E.OrderMessage_DECLINED || params.TotalAmount1000 < 0 || !validCurrency(params.TotalCurrencyCode) { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index f06a1ab11..1ed6d2066 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -84,6 +84,31 @@ func TestBuildBusinessOrderMessageMatchesWebGenerator(t *testing.T) { } } +func TestBusinessProductListDescriptionAndOrderTokenAreOptional(t *testing.T) { + owner := types.NewJID("15550001", types.DefaultUserServer) + list, err := BuildBusinessProductListMessage(BusinessProductListMessageParams{ + BusinessOwnerJID: owner, Title: "Seasonal", ButtonText: "View products", + Sections: []BusinessProductSection{{ProductIDs: []string{"p-tea"}}}, + }) + if err != nil { + t.Fatalf("product list without description failed: %v", err) + } + if list.GetListMessage().Description != nil { + t.Fatalf("omitted description was encoded: %q", list.GetListMessage().GetDescription()) + } + + order, err := BuildBusinessOrderMessage(BusinessOrderMessageParams{ + OrderID: "o-100", ItemCount: 1, Status: waE2E.OrderMessage_INQUIRY, + SellerJID: owner, TotalCurrencyCode: "USD", + }) + if err != nil { + t.Fatalf("order without token failed: %v", err) + } + if order.GetOrderMessage().Token != nil { + t.Fatalf("omitted token was encoded: %q", order.GetOrderMessage().GetToken()) + } +} + func TestBuildBusinessListAndNativeFlowButtonsMatchWebGenerators(t *testing.T) { list, err := BuildBusinessListMessage(BusinessListMessageParams{ Title: "Support", Description: "Choose a topic", ButtonText: "View topics", Footer: "Synthetic support", @@ -214,7 +239,6 @@ func TestBusinessProductListAndNativeFlowTextLimits(t *testing.T) { Sections: []BusinessProductSection{{Title: "Section", ProductIDs: []string{"p"}}}, } productMutations := map[string]func(*BusinessProductListMessageParams){ - "missing body": func(params *BusinessProductListMessageParams) { params.Description = "" }, "header": func(params *BusinessProductListMessageParams) { params.Title = strings.Repeat("h", 61) }, "body": func(params *BusinessProductListMessageParams) { params.Description = strings.Repeat("b", 1025) }, "button": func(params *BusinessProductListMessageParams) { params.ButtonText = strings.Repeat("c", 21) }, From 55608cfbf1a46b5ca0ff6fa7ef51b95e981c5d0e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 10:35:07 +0300 Subject: [PATCH 070/163] fix: preserve sale price presence --- business_message_builders.go | 11 ++++++++--- business_message_builders_test.go | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 7b3ce0842..87b8821da 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -21,6 +21,7 @@ type BusinessProductMessageParams struct { CurrencyCode string PriceAmount1000 int64 SalePriceAmount1000 int64 + SalePricePresent bool RetailerID string URL string ProductImageCount uint32 @@ -141,14 +142,14 @@ func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Me if strings.TrimSpace(params.ProductID) == "" || !bounded(params.ProductID, 256) || strings.TrimSpace(params.Title) == "" || !bounded(params.Title, 256) { return nil, errors.New("invalid business product identity") } - if !bounded(params.Description, 4096) || !bounded(params.RetailerID, 256) || !bounded(params.URL, 2048) || !bounded(params.Body, 4096) || !bounded(params.Footer, 256) { + if !bounded(params.Description, 4096) || !bounded(params.RetailerID, 256) || !bounded(params.URL, 2048) || !bounded(params.Body, 1024) || !bounded(params.Footer, 60) { return nil, errors.New("business product message field is too large") } if params.PriceAmount1000 < 0 || params.SalePriceAmount1000 < 0 { return nil, errors.New("invalid business product price") } pricePresent := params.PriceAmount1000 != 0 || params.CurrencyCode != "" - if !pricePresent && params.SalePriceAmount1000 > 0 { + if !pricePresent && (params.SalePriceAmount1000 > 0 || params.SalePricePresent) { return nil, errors.New("business product sale price requires a base price") } if pricePresent && !validCurrency(params.CurrencyCode) { @@ -167,11 +168,15 @@ func BuildBusinessProductMessage(params BusinessProductMessageParams) (*waE2E.Me if pricePresent { priceAmount1000 = proto.Int64(params.PriceAmount1000) } + salePriceAmount1000 := optionalPositiveInt64(params.SalePriceAmount1000) + if params.SalePricePresent { + salePriceAmount1000 = proto.Int64(params.SalePriceAmount1000) + } return &waE2E.Message{ProductMessage: &waE2E.ProductMessage{ Product: &waE2E.ProductMessage_ProductSnapshot{ ProductImage: params.ProductImage, ProductID: proto.String(params.ProductID), Title: proto.String(params.Title), Description: optionalString(params.Description), CurrencyCode: optionalString(params.CurrencyCode), - PriceAmount1000: priceAmount1000, SalePriceAmount1000: optionalPositiveInt64(params.SalePriceAmount1000), + PriceAmount1000: priceAmount1000, SalePriceAmount1000: salePriceAmount1000, RetailerID: optionalString(params.RetailerID), URL: optionalString(params.URL), ProductImageCount: optionalPositiveUint32(params.ProductImageCount), }, BusinessOwnerJID: proto.String(params.BusinessOwnerJID.ToNonAD().String()), Body: optionalString(params.Body), Footer: optionalString(params.Footer), ContextInfo: params.ContextInfo, diff --git a/business_message_builders_test.go b/business_message_builders_test.go index 1ed6d2066..22014be4b 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -49,6 +49,25 @@ func TestBuildBusinessProductMessagePreservesExplicitZeroPrice(t *testing.T) { } } +func TestBuildBusinessProductMessagePreservesExplicitZeroSalePrice(t *testing.T) { + msg, err := BuildBusinessProductMessage(BusinessProductMessageParams{ + BusinessOwnerJID: types.NewJID("15550001", types.DefaultUserServer), + ProductID: "p-sale", + Title: "Sale sample", + CurrencyCode: "USD", + PriceAmount1000: 1000, + SalePriceAmount1000: 0, + SalePricePresent: true, + }) + if err != nil { + t.Fatal(err) + } + salePrice := msg.GetProductMessage().GetProduct().SalePriceAmount1000 + if salePrice == nil || *salePrice != 0 { + t.Fatalf("explicit zero sale price was not preserved: %#v", salePrice) + } +} + func TestBuildBusinessProductListMessageMatchesWebGenerator(t *testing.T) { msg, err := BuildBusinessProductListMessage(BusinessProductListMessageParams{ BusinessOwnerJID: types.NewJID("15550001", types.DefaultUserServer), @@ -190,6 +209,8 @@ func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { "non-HTTPS URL": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", CurrencyCode: "USD", URL: "http://synthetic.invalid/product"}, "sale without price": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", SalePriceAmount1000: 1000}, "too many images": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", CurrencyCode: "USD", ProductImageCount: 11}, + "oversized body": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", Body: strings.Repeat("b", 1025)}, + "oversized footer": {BusinessOwnerJID: owner, ProductID: "p", Title: "Tea", Footer: strings.Repeat("f", 61)}, } { t.Run(name, func(t *testing.T) { if _, err := BuildBusinessProductMessage(params); err == nil { From 03f5db1022833153ea32af8ba849e86c1d8f19c2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 10:45:55 +0300 Subject: [PATCH 071/163] fix: reject oversized list sections early --- business_message_builders.go | 16 +++++++-------- business_message_builders_test.go | 33 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 87b8821da..fea01d5b3 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -200,6 +200,10 @@ func BuildBusinessProductListMessage(params BusinessProductListMessageParams) (* if !bounded(section.Title, 24) || len(section.ProductIDs) == 0 || (len(params.Sections) > 1 && strings.TrimSpace(section.Title) == "") { return nil, fmt.Errorf("invalid business product section %d", index) } + if len(section.ProductIDs) > 30-productCount { + return nil, errors.New("business product list exceeds 30 products") + } + productCount += len(section.ProductIDs) products := make([]*waE2E.ListMessage_Product, len(section.ProductIDs)) for productIndex, productID := range section.ProductIDs { if strings.TrimSpace(productID) == "" || !bounded(productID, 256) { @@ -210,13 +214,9 @@ func BuildBusinessProductListMessage(params BusinessProductListMessageParams) (* } seen[productID] = struct{}{} products[productIndex] = &waE2E.ListMessage_Product{ProductID: proto.String(productID)} - productCount++ } sections[index] = &waE2E.ListMessage_ProductSection{Title: optionalString(section.Title), Products: products} } - if productCount > 30 { - return nil, errors.New("business product list exceeds 30 products") - } return &waE2E.Message{ListMessage: &waE2E.ListMessage{ Title: proto.String(params.Title), Description: optionalString(params.Description), ButtonText: proto.String(params.ButtonText), ListType: waE2E.ListMessage_PRODUCT_LIST.Enum(), FooterText: optionalString(params.Footer), @@ -259,6 +259,10 @@ func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, if !bounded(section.Title, 24) || len(section.Rows) == 0 || (len(params.Sections) > 1 && strings.TrimSpace(section.Title) == "") { return nil, fmt.Errorf("invalid business list section %d", sectionIndex) } + if len(section.Rows) > 10-rowCount { + return nil, errors.New("business list exceeds 10 rows") + } + rowCount += len(section.Rows) rows := make([]*waE2E.ListMessage_Row, len(section.Rows)) for rowIndex, row := range section.Rows { if strings.TrimSpace(row.ID) == "" || !bounded(row.ID, 200) || strings.TrimSpace(row.Title) == "" || !bounded(row.Title, 24) || !bounded(row.Description, 72) { @@ -269,13 +273,9 @@ func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, } seen[row.ID] = struct{}{} rows[rowIndex] = &waE2E.ListMessage_Row{RowID: proto.String(row.ID), Title: proto.String(row.Title), Description: optionalString(row.Description)} - rowCount++ } sections[sectionIndex] = &waE2E.ListMessage_Section{Title: optionalString(section.Title), Rows: rows} } - if rowCount > 10 { - return nil, errors.New("business list exceeds 10 rows") - } return &waE2E.Message{ListMessage: &waE2E.ListMessage{ Title: proto.String(params.Title), Description: optionalString(params.Description), ButtonText: proto.String(params.ButtonText), ListType: waE2E.ListMessage_SINGLE_SELECT.Enum(), Sections: sections, FooterText: optionalString(params.Footer), ContextInfo: params.ContextInfo, diff --git a/business_message_builders_test.go b/business_message_builders_test.go index 22014be4b..8f84c68ae 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -200,6 +200,39 @@ func TestBuildBusinessListRequiresBodyAndCapsRows(t *testing.T) { } } +func TestBusinessListBuildersRejectOversizedSectionsBeforeAllocating(t *testing.T) { + owner := types.NewJID("15550001", types.DefaultUserServer) + productIDs := make([]string, 1000) + rows := make([]BusinessListRow, 1000) + for index := range productIDs { + productIDs[index] = fmt.Sprintf("product-%d", index) + rows[index] = BusinessListRow{ID: fmt.Sprintf("row-%d", index), Title: "Row"} + } + + productAllocs := testing.AllocsPerRun(1, func() { + _, _ = BuildBusinessProductListMessage(BusinessProductListMessageParams{ + BusinessOwnerJID: owner, + Title: "Products", + ButtonText: "View", + Sections: []BusinessProductSection{{ProductIDs: productIDs}}, + }) + }) + if productAllocs > 50 { + t.Fatalf("oversized product section allocated %.0f objects", productAllocs) + } + + rowAllocs := testing.AllocsPerRun(1, func() { + _, _ = BuildBusinessListMessage(BusinessListMessageParams{ + Description: "Choose a row", + ButtonText: "View", + Sections: []BusinessListSection{{Rows: rows}}, + }) + }) + if rowAllocs > 50 { + t.Fatalf("oversized row section allocated %.0f objects", rowAllocs) + } +} + func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { if _, err := BuildBusinessProductMessage(BusinessProductMessageParams{ProductID: "p", Title: "Tea", CurrencyCode: "USD"}); err == nil { t.Fatal("expected missing owner to fail") From c9bc4ac18485ddc4226c3d372c1a29567459f3fb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 15:29:36 +0300 Subject: [PATCH 072/163] Add business account capability reads --- business_account.go | 343 ++++++++++++++++++++++++++++++++++++++ business_account_test.go | 135 +++++++++++++++ types/business_account.go | 63 +++++++ 3 files changed, 541 insertions(+) create mode 100644 business_account.go create mode 100644 business_account_test.go create mode 100644 types/business_account.go diff --git a/business_account.go b/business_account.go new file mode 100644 index 000000000..234df6115 --- /dev/null +++ b/business_account.go @@ -0,0 +1,343 @@ +package whatsmeow + +import ( + "context" + "fmt" + "strconv" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +const ( + maxBusinessAccountIDBytes = 256 + maxBusinessAccountNameBytes = 512 + maxBusinessAccountURLBytes = 4096 + maxBusinessEligibilityParamsBytes = 16 * 1024 +) + +var businessEligibilityFeatures = []types.BusinessFeature{ + types.BusinessFeatureMetaVerified, + types.BusinessFeatureMarketingMessages, + types.BusinessFeatureGenAI, + types.BusinessFeatureGenAIImage, + types.BusinessFeatureMetaOne, + types.BusinessFeatureBBPro, +} + +func businessLinkedAccountsQuery() infoQuery { + return infoQuery{ + Namespace: "fb:thrift_iq", + Type: iqGet, + To: types.ServerJID, + SMaxID: "42", + Content: []waBinary.Node{{Tag: "linked_accounts"}}, + } +} + +func businessEligibilityQuery(features []types.BusinessFeature) (infoQuery, error) { + if len(features) == 0 { + features = businessEligibilityFeatures + } + attrs := make(waBinary.Attrs, len(features)) + for _, feature := range features { + if !isBusinessEligibilityFeature(feature) { + return infoQuery{}, fmt.Errorf("unknown business feature %q", feature) + } + if _, exists := attrs[string(feature)]; exists { + return infoQuery{}, fmt.Errorf("duplicate business feature %q", feature) + } + attrs[string(feature)] = "true" + } + return infoQuery{ + Namespace: "w:biz", + Type: iqGet, + To: types.ServerJID, + SMaxID: "139", + Content: []waBinary.Node{{Tag: "features", Attrs: attrs}}, + }, nil +} + +func isBusinessEligibilityFeature(feature types.BusinessFeature) bool { + for _, known := range businessEligibilityFeatures { + if feature == known { + return true + } + } + return false +} + +func (cli *Client) GetBusinessLinkedAccounts(ctx context.Context) (*types.BusinessLinkedAccounts, error) { + response, err := cli.sendIQ(ctx, businessLinkedAccountsQuery()) + if err != nil { + return nil, fmt.Errorf("get linked business accounts: %w", err) + } + return parseBusinessLinkedAccounts(response) +} + +func (cli *Client) GetBusinessEligibility(ctx context.Context, features ...types.BusinessFeature) (*types.BusinessEligibility, error) { + query, err := businessEligibilityQuery(features) + if err != nil { + return nil, err + } + response, err := cli.sendIQ(ctx, query) + if err != nil { + return nil, fmt.Errorf("get business eligibility: %w", err) + } + return parseBusinessEligibility(response) +} + +func parseBusinessLinkedAccounts(response *waBinary.Node) (*types.BusinessLinkedAccounts, error) { + root, ok := response.GetOptionalChildByTag("linked_accounts") + if !ok { + return nil, &ElementMissingError{Tag: "linked_accounts", In: "business linked accounts response"} + } + result := &types.BusinessLinkedAccounts{} + for _, node := range root.GetChildren() { + var err error + switch node.Tag { + case "fb_page": + result.FacebookPage, err = parseBusinessFacebookPage(node) + case "fb_biz": + result.FacebookBusiness, err = parseBusinessFacebookBusiness(node) + case "ig_professional": + result.InstagramProfessional, err = parseBusinessInstagram(node) + case "whatsapp_ad_identity": + result.WhatsAppAdIdentity, err = parseBusinessWhatsAppAdIdentity(node) + } + if err != nil { + return nil, err + } + } + return result, nil +} + +func parseBusinessFacebookPage(node waBinary.Node) (*types.BusinessFacebookPage, error) { + attrs := node.AttrGetter() + page := &types.BusinessFacebookPage{ID: attrs.String("id")} + if err := attrs.Error(); err != nil { + return nil, fmt.Errorf("parse Facebook Page: %w", err) + } + if err := validateBusinessAccountText("Facebook Page ID", page.ID, maxBusinessAccountIDBytes); err != nil { + return nil, err + } + var err error + if page.DisplayName, err = requiredBusinessNodeText(node, "display_name", maxBusinessAccountNameBytes); err != nil { + return nil, err + } + if page.ProfilePictureURL, err = requiredBusinessPictureURL(node); err != nil { + return nil, err + } + if page.ShowOnProfile, err = requiredBusinessNodeBool(node, "show_on_profile"); err != nil { + return nil, err + } + if sync, ok := node.GetOptionalChildByTag("profile_sync"); ok { + page.ProfileSync, err = requiredBusinessEnumAttr(sync, "state", "disable", "import") + if err != nil { + return nil, err + } + } + if page.HasActiveCTWAAd, page.HasCreatedAd, err = requiredBusinessAdStatus(node); err != nil { + return nil, err + } + button, ok := node.GetOptionalChildByTag("whatsapp_as_page_button") + if !ok { + return nil, &ElementMissingError{Tag: "whatsapp_as_page_button", In: "Facebook Page"} + } + state, err := requiredBusinessEnumAttr(button, "state", "off", "on") + if err != nil { + return nil, err + } + page.WhatsAppAsPageButton = state == "on" + return page, nil +} + +func parseBusinessFacebookBusiness(node waBinary.Node) (*types.BusinessFacebookBusiness, error) { + attrs := node.AttrGetter() + business := &types.BusinessFacebookBusiness{ID: attrs.String("id")} + if err := attrs.Error(); err != nil { + return nil, fmt.Errorf("parse Facebook business: %w", err) + } + if err := validateBusinessAccountText("Facebook business ID", business.ID, maxBusinessAccountIDBytes); err != nil { + return nil, err + } + var err error + if business.DisplayName, err = requiredBusinessNodeText(node, "display_name", maxBusinessAccountNameBytes); err != nil { + return nil, err + } + if catalog, ok := node.GetOptionalChildByTag("catalog"); ok { + catalogAttrs := catalog.AttrGetter() + business.CatalogID = catalogAttrs.String("id") + business.CatalogState = catalogAttrs.String("state") + if err = catalogAttrs.Error(); err != nil { + return nil, fmt.Errorf("parse linked catalog: %w", err) + } + if err = validateBusinessAccountText("catalog ID", business.CatalogID, maxBusinessAccountIDBytes); err != nil { + return nil, err + } + if business.CatalogState != "disable" && business.CatalogState != "import" { + return nil, fmt.Errorf("invalid catalog state %q", business.CatalogState) + } + } + return business, nil +} + +func parseBusinessInstagram(node waBinary.Node) (*types.BusinessInstagramProfessional, error) { + instagram := &types.BusinessInstagramProfessional{} + var err error + if instagram.Handle, err = requiredBusinessNodeText(node, "ig_handle", maxBusinessAccountNameBytes); err != nil { + return nil, err + } + if instagram.DisplayName, err = requiredBusinessNodeText(node, "display_name", maxBusinessAccountNameBytes); err != nil { + return nil, err + } + if instagram.ProfilePictureURL, err = requiredBusinessPictureURL(node); err != nil { + return nil, err + } + if instagram.ShowOnProfile, err = requiredBusinessNodeBool(node, "show_on_profile"); err != nil { + return nil, err + } + return instagram, nil +} + +func parseBusinessWhatsAppAdIdentity(node waBinary.Node) (*types.BusinessWhatsAppAdIdentity, error) { + attrs := node.AttrGetter() + identity := &types.BusinessWhatsAppAdIdentity{ID: attrs.String("id")} + if err := attrs.Error(); err != nil { + return nil, fmt.Errorf("parse WhatsApp ad identity: %w", err) + } + if err := validateBusinessAccountText("WhatsApp ad identity ID", identity.ID, maxBusinessAccountIDBytes); err != nil { + return nil, err + } + var err error + identity.HasActiveCTWAAd, identity.HasCreatedAd, err = requiredBusinessAdStatus(node) + if err != nil { + return nil, err + } + return identity, nil +} + +func requiredBusinessAdStatus(node waBinary.Node) (bool, bool, error) { + status, ok := node.GetOptionalChildByTag("ad_status") + if !ok { + return false, false, &ElementMissingError{Tag: "ad_status", In: node.Tag} + } + attrs := status.AttrGetter() + active := attrs.Bool("has_active_ctwa_ad") + created := attrs.Bool("has_created_ad") + if err := attrs.Error(); err != nil { + return false, false, fmt.Errorf("parse %s ad status: %w", node.Tag, err) + } + return active, created, nil +} + +func requiredBusinessPictureURL(node waBinary.Node) (string, error) { + picture, ok := node.GetOptionalChildByTag("profile_picture") + if !ok { + return "", &ElementMissingError{Tag: "profile_picture", In: node.Tag} + } + return requiredBusinessNodeText(picture, "url", maxBusinessAccountURLBytes) +} + +func requiredBusinessNodeText(node waBinary.Node, tag string, maxBytes int) (string, error) { + child, ok := node.GetOptionalChildByTag(tag) + if !ok { + return "", &ElementMissingError{Tag: tag, In: node.Tag} + } + content, ok := child.Content.([]byte) + if !ok { + return "", fmt.Errorf("%s in %s has invalid content type %T", tag, node.Tag, child.Content) + } + value := string(content) + if err := validateBusinessAccountText(tag, value, maxBytes); err != nil { + return "", err + } + return value, nil +} + +func requiredBusinessNodeBool(node waBinary.Node, tag string) (bool, error) { + value, err := requiredBusinessNodeText(node, tag, 5) + if err != nil { + return false, err + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("invalid %s value %q: %w", tag, value, err) + } + return parsed, nil +} + +func requiredBusinessEnumAttr(node waBinary.Node, attr string, allowed ...string) (string, error) { + attrs := node.AttrGetter() + value := attrs.String(attr) + if err := attrs.Error(); err != nil { + return "", fmt.Errorf("parse %s: %w", node.Tag, err) + } + for _, candidate := range allowed { + if value == candidate { + return value, nil + } + } + return "", fmt.Errorf("invalid %s %s %q", node.Tag, attr, value) +} + +func validateBusinessAccountText(field, value string, maxBytes int) error { + if value == "" { + return fmt.Errorf("%s is empty", field) + } + if len(value) > maxBytes { + return fmt.Errorf("%s exceeds %d bytes", field, maxBytes) + } + return nil +} + +func parseBusinessEligibility(response *waBinary.Node) (*types.BusinessEligibility, error) { + result := &types.BusinessEligibility{Features: make([]types.BusinessFeatureEligibility, 0, len(businessEligibilityFeatures))} + for _, node := range response.GetChildren() { + feature := types.BusinessFeature(node.Tag) + if !isBusinessEligibilityFeature(feature) { + continue + } + attrs := node.AttrGetter() + entry := types.BusinessFeatureEligibility{Feature: feature, Status: attrs.String("status")} + if expiration, ok := attrs.GetInt64("expiration", false); ok { + entry.Expiration = expiration + } + entry.AdditionalParams = attrs.OptionalString("additional_params") + if value, ok := attrs.GetBool("should_show_privacy_interstitial_to_new_users", false); ok { + entry.ShowPrivacyInterstitial = &value + } + if value, ok := attrs.GetBool("v1_enabled", false); ok { + entry.V1Enabled = &value + } + if err := attrs.Error(); err != nil { + return nil, fmt.Errorf("parse %s eligibility: %w", feature, err) + } + if err := validateBusinessEligibilityStatus(feature, entry.Status); err != nil { + return nil, err + } + if len(entry.AdditionalParams) > maxBusinessEligibilityParamsBytes { + return nil, fmt.Errorf("%s additional_params exceeds %d bytes", feature, maxBusinessEligibilityParamsBytes) + } + result.Features = append(result.Features, entry) + } + return result, nil +} + +func validateBusinessEligibilityStatus(feature types.BusinessFeature, status string) error { + var allowed []string + switch feature { + case types.BusinessFeatureMarketingMessages: + allowed = []string{"FAIL", "PAUSED", "SUCCESS", "WARNING"} + case types.BusinessFeatureBBPro: + allowed = []string{"ELIGIBLE_TO_ONBOARD", "NOT_ELIGIBLE", "ONBOARDED"} + default: + allowed = []string{"FAIL", "SUCCESS"} + } + for _, candidate := range allowed { + if status == candidate { + return nil + } + } + return fmt.Errorf("invalid %s eligibility status %q", feature, status) +} diff --git a/business_account_test.go b/business_account_test.go new file mode 100644 index 000000000..3e9bb922e --- /dev/null +++ b/business_account_test.go @@ -0,0 +1,135 @@ +package whatsmeow + +import ( + "strings" + "testing" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +func TestBusinessLinkedAccountsQuery(t *testing.T) { + query := businessLinkedAccountsQuery() + if query.Namespace != "fb:thrift_iq" || query.Type != iqGet || query.To != types.ServerJID || query.SMaxID != "42" { + t.Fatalf("unexpected linked accounts query: %#v", query) + } + content, ok := query.Content.([]waBinary.Node) + if !ok || len(content) != 1 || content[0].Tag != "linked_accounts" { + t.Fatalf("unexpected linked accounts content: %#v", query.Content) + } +} + +func TestParseBusinessLinkedAccounts(t *testing.T) { + response := waBinary.Node{Tag: "iq", Content: []waBinary.Node{{ + Tag: "linked_accounts", + Content: []waBinary.Node{ + {Tag: "fb_page", Attrs: waBinary.Attrs{"id": "page-1"}, Content: []waBinary.Node{ + {Tag: "profile_sync", Attrs: waBinary.Attrs{"state": "import"}}, + {Tag: "display_name", Content: []byte("Synthetic Page")}, + {Tag: "ad_status", Attrs: waBinary.Attrs{"has_active_ctwa_ad": "true", "has_created_ad": "false"}}, + {Tag: "profile_picture", Content: []waBinary.Node{{Tag: "bytes", Content: []byte("ignored")}, {Tag: "url", Content: []byte("https://example.test/page.jpg")}}}, + {Tag: "show_on_profile", Content: []byte("true")}, + {Tag: "whatsapp_as_page_button", Attrs: waBinary.Attrs{"state": "on"}}, + }}, + {Tag: "fb_biz", Attrs: waBinary.Attrs{"id": "business-1"}, Content: []waBinary.Node{ + {Tag: "catalog", Attrs: waBinary.Attrs{"id": "catalog-1", "state": "import"}}, + {Tag: "display_name", Content: []byte("Synthetic Business")}, + }}, + {Tag: "ig_professional", Content: []waBinary.Node{ + {Tag: "ig_handle", Content: []byte("synthetic_shop")}, + {Tag: "profile_picture", Content: []waBinary.Node{{Tag: "url", Content: []byte("https://example.test/ig.jpg")}}}, + {Tag: "display_name", Content: []byte("Synthetic Shop")}, + {Tag: "show_on_profile", Content: []byte("false")}, + }}, + {Tag: "whatsapp_ad_identity", Attrs: waBinary.Attrs{"id": "identity-1"}, Content: []waBinary.Node{ + {Tag: "ad_status", Attrs: waBinary.Attrs{"has_active_ctwa_ad": "false", "has_created_ad": "true"}}, + }}, + }, + }}} + + accounts, err := parseBusinessLinkedAccounts(&response) + if err != nil { + t.Fatal(err) + } + if accounts.FacebookPage == nil || accounts.FacebookPage.ID != "page-1" || !accounts.FacebookPage.ShowOnProfile || accounts.FacebookPage.ProfilePictureURL != "https://example.test/page.jpg" { + t.Fatalf("unexpected Facebook Page: %#v", accounts.FacebookPage) + } + if accounts.FacebookBusiness == nil || accounts.FacebookBusiness.CatalogID != "catalog-1" || accounts.FacebookBusiness.CatalogState != "import" { + t.Fatalf("unexpected Facebook business: %#v", accounts.FacebookBusiness) + } + if accounts.InstagramProfessional == nil || accounts.InstagramProfessional.Handle != "synthetic_shop" || accounts.InstagramProfessional.ShowOnProfile { + t.Fatalf("unexpected Instagram account: %#v", accounts.InstagramProfessional) + } + if accounts.WhatsAppAdIdentity == nil || accounts.WhatsAppAdIdentity.HasActiveCTWAAd || !accounts.WhatsAppAdIdentity.HasCreatedAd { + t.Fatalf("unexpected WhatsApp ad identity: %#v", accounts.WhatsAppAdIdentity) + } +} + +func TestParseBusinessLinkedAccountsRejectsMalformedValues(t *testing.T) { + response := waBinary.Node{Tag: "iq", Content: []waBinary.Node{{Tag: "linked_accounts", Content: []waBinary.Node{{ + Tag: "fb_page", Attrs: waBinary.Attrs{"id": "page-1"}, Content: []waBinary.Node{ + {Tag: "display_name", Content: []byte("Synthetic Page")}, + {Tag: "ad_status", Attrs: waBinary.Attrs{"has_active_ctwa_ad": "maybe", "has_created_ad": "false"}}, + {Tag: "profile_picture", Content: []waBinary.Node{{Tag: "url", Content: []byte("https://example.test/page.jpg")}}}, + {Tag: "show_on_profile", Content: []byte("true")}, + {Tag: "whatsapp_as_page_button", Attrs: waBinary.Attrs{"state": "on"}}, + }, + }}}}} + if _, err := parseBusinessLinkedAccounts(&response); err == nil { + t.Fatal("expected malformed boolean error") + } +} + +func TestBusinessEligibilityQuery(t *testing.T) { + query, err := businessEligibilityQuery(nil) + if err != nil { + t.Fatal(err) + } + if query.Namespace != "w:biz" || query.Type != iqGet || query.To != types.ServerJID || query.SMaxID != "139" { + t.Fatalf("unexpected eligibility query: %#v", query) + } + content := query.Content.([]waBinary.Node) + attrs := content[0].Attrs + for _, feature := range businessEligibilityFeatures { + if attrs[string(feature)] != "true" { + t.Fatalf("feature %q was not requested: %#v", feature, attrs) + } + } + if _, err = businessEligibilityQuery([]types.BusinessFeature{types.BusinessFeatureGenAI, types.BusinessFeatureGenAI}); err == nil { + t.Fatal("expected duplicate feature error") + } + if _, err = businessEligibilityQuery([]types.BusinessFeature{"unknown"}); err == nil { + t.Fatal("expected unknown feature error") + } +} + +func TestParseBusinessEligibility(t *testing.T) { + response := waBinary.Node{Tag: "iq", Content: []waBinary.Node{ + {Tag: "meta_verified", Attrs: waBinary.Attrs{"status": "SUCCESS", "additional_params": "{}", "should_show_privacy_interstitial_to_new_users": "false"}}, + {Tag: "marketing_messages", Attrs: waBinary.Attrs{"status": "PAUSED", "expiration": "1720000000"}}, + {Tag: "genai", Attrs: waBinary.Attrs{"status": "SUCCESS", "v1_enabled": "true"}}, + {Tag: "bb_pro", Attrs: waBinary.Attrs{"status": "ELIGIBLE_TO_ONBOARD"}}, + }} + eligibility, err := parseBusinessEligibility(&response) + if err != nil { + t.Fatal(err) + } + if len(eligibility.Features) != 4 || eligibility.Features[1].Expiration != 1720000000 { + t.Fatalf("unexpected eligibility: %#v", eligibility) + } + if eligibility.Features[0].ShowPrivacyInterstitial == nil || *eligibility.Features[0].ShowPrivacyInterstitial { + t.Fatalf("unexpected privacy interstitial value: %#v", eligibility.Features[0]) + } + if eligibility.Features[2].V1Enabled == nil || !*eligibility.Features[2].V1Enabled { + t.Fatalf("unexpected genai value: %#v", eligibility.Features[2]) + } +} + +func TestParseBusinessEligibilityRejectsOversizedAdditionalParams(t *testing.T) { + response := waBinary.Node{Tag: "iq", Content: []waBinary.Node{{ + Tag: "meta_verified", Attrs: waBinary.Attrs{"status": "SUCCESS", "additional_params": strings.Repeat("x", maxBusinessEligibilityParamsBytes+1)}, + }}} + if _, err := parseBusinessEligibility(&response); err == nil { + t.Fatal("expected oversized additional params error") + } +} diff --git a/types/business_account.go b/types/business_account.go new file mode 100644 index 000000000..e57743856 --- /dev/null +++ b/types/business_account.go @@ -0,0 +1,63 @@ +package types + +type BusinessLinkedAccounts struct { + FacebookPage *BusinessFacebookPage `json:"facebook_page,omitempty"` + FacebookBusiness *BusinessFacebookBusiness `json:"facebook_business,omitempty"` + InstagramProfessional *BusinessInstagramProfessional `json:"instagram_professional,omitempty"` + WhatsAppAdIdentity *BusinessWhatsAppAdIdentity `json:"whatsapp_ad_identity,omitempty"` +} + +type BusinessFacebookPage struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + ProfileSync string `json:"profile_sync,omitempty"` + HasActiveCTWAAd bool `json:"has_active_ctwa_ad"` + HasCreatedAd bool `json:"has_created_ad"` + ProfilePictureURL string `json:"profile_picture_url"` + ShowOnProfile bool `json:"show_on_profile"` + WhatsAppAsPageButton bool `json:"whatsapp_as_page_button"` +} + +type BusinessFacebookBusiness struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + CatalogID string `json:"catalog_id,omitempty"` + CatalogState string `json:"catalog_state,omitempty"` +} + +type BusinessInstagramProfessional struct { + Handle string `json:"handle"` + DisplayName string `json:"display_name"` + ProfilePictureURL string `json:"profile_picture_url"` + ShowOnProfile bool `json:"show_on_profile"` +} + +type BusinessWhatsAppAdIdentity struct { + ID string `json:"id"` + HasActiveCTWAAd bool `json:"has_active_ctwa_ad"` + HasCreatedAd bool `json:"has_created_ad"` +} + +type BusinessFeature string + +const ( + BusinessFeatureMetaVerified BusinessFeature = "meta_verified" + BusinessFeatureMarketingMessages BusinessFeature = "marketing_messages" + BusinessFeatureGenAI BusinessFeature = "genai" + BusinessFeatureGenAIImage BusinessFeature = "genai_image" + BusinessFeatureMetaOne BusinessFeature = "meta_one" + BusinessFeatureBBPro BusinessFeature = "bb_pro" +) + +type BusinessFeatureEligibility struct { + Feature BusinessFeature `json:"feature"` + Status string `json:"status"` + Expiration int64 `json:"expiration,omitempty"` + AdditionalParams string `json:"additional_params,omitempty"` + ShowPrivacyInterstitial *bool `json:"show_privacy_interstitial_to_new_users,omitempty"` + V1Enabled *bool `json:"v1_enabled,omitempty"` +} + +type BusinessEligibility struct { + Features []BusinessFeatureEligibility `json:"features"` +} From f20cd7140bec57650a5a88742a49f29ba7684da5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 10:54:17 +0300 Subject: [PATCH 073/163] fix: redact linked account payloads --- binary/xml.go | 2 +- binary/xml_test.go | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/binary/xml.go b/binary/xml.go index 76bd83a7f..e90152aa9 100644 --- a/binary/xml.go +++ b/binary/xml.go @@ -27,7 +27,7 @@ func sensitiveXMLAttribute(key string) bool { func sensitiveXMLNodeContent(tag string) bool { switch tag { - case "access_token", "address", "code", "description", "email", "session_cookies", "token", "wa_ad_account_nonce", "website": + case "access_token", "address", "code", "description", "email", "linked_accounts", "session_cookies", "token", "wa_ad_account_nonce", "website": return true default: return false diff --git a/binary/xml_test.go b/binary/xml_test.go index 59f3f482a..a5f4f00ac 100644 --- a/binary/xml_test.go +++ b/binary/xml_test.go @@ -47,3 +47,26 @@ func TestNodeStringRedactsSensitiveAttributes(t *testing.T) { t.Fatalf("unexpected redacted node: %s", logged) } } + +func TestNodeStringRedactsLinkedAccountPayload(t *testing.T) { + logged := (Node{ + Tag: "linked_accounts", + Content: []Node{{ + Tag: "fb_page", + Attrs: Attrs{"id": "facebook-page-100"}, + Content: []Node{ + {Tag: "display_name", Content: []byte("Private Store")}, + {Tag: "ig_handle", Content: "private_handle"}, + {Tag: "profile_picture", Content: []Node{{Tag: "url", Content: []byte("https://private.invalid/picture?access=secret")}}}, + }, + }}, + }).String() + for _, secret := range []string{"facebook-page-100", "Private Store", "private_handle", "private.invalid", "secret"} { + if strings.Contains(logged, secret) { + t.Fatalf("linked-account payload leaked %q: %s", secret, logged) + } + } + if logged != "[redacted]" { + t.Fatalf("unexpected linked-account redaction: %s", logged) + } +} From 485493bdbb19746d0728af6b738c6f412b42e18e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 15:57:57 +0300 Subject: [PATCH 074/163] Fix business metadata on response messages --- send.go | 4 ---- send_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 send_test.go diff --git a/send.go b/send.go index 757bfbce1..4854a01ca 100644 --- a/send.go +++ b/send.go @@ -997,12 +997,8 @@ func getButtonTypeFromMessage(msg *waE2E.Message) string { return getButtonTypeFromMessage(msg.EphemeralMessage.Message) case msg.ButtonsMessage != nil: return "buttons" - case msg.ButtonsResponseMessage != nil: - return "buttons_response" case msg.ListMessage != nil: return "list" - case msg.ListResponseMessage != nil: - return "list_response" case msg.InteractiveResponseMessage != nil: return "interactive_response" default: diff --git a/send_test.go b/send_test.go new file mode 100644 index 000000000..4b43aa33f --- /dev/null +++ b/send_test.go @@ -0,0 +1,24 @@ +package whatsmeow + +import ( + "testing" + + waE2E "go.mau.fi/whatsmeow/proto/waE2E" +) + +func TestButtonAndListResponsesDoNotRequestBusinessMetadata(t *testing.T) { + tests := []struct { + name string + msg *waE2E.Message + }{ + {name: "buttons response", msg: &waE2E.Message{ButtonsResponseMessage: &waE2E.ButtonsResponseMessage{}}}, + {name: "list response", msg: &waE2E.Message{ListResponseMessage: &waE2E.ListResponseMessage{}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := getButtonTypeFromMessage(tc.msg); got != "" { + t.Fatalf("response requested unexpected business metadata type %q", got) + } + }) + } +} From 3cfb5ff41c40c3529d1283a8b0ce03021887a069 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 16:07:17 +0300 Subject: [PATCH 075/163] Skip empty PN to LID migration transactions --- go.mod | 1 + go.sum | 1 + store/sqlstore/store.go | 37 ++++++++++++++----- store/sqlstore/store_test.go | 36 ++++++++++++++++++ store/sqlstore/upgrades/00-latest-schema.sql | 3 +- .../16-sender-key-migration-index.sql | 2 + 6 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 store/sqlstore/upgrades/16-sender-key-migration-index.sql diff --git a/go.mod b/go.mod index 2661c2d4f..f49a2796b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 toolchain go1.26.5 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/beeper/argo-go v1.1.2 github.com/coder/websocket v1.8.15 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 24e9dd851..f778fff7f 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index bb41f0dd2..5b459d9d1 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -253,6 +253,11 @@ const ( WHERE our_jid=$1 AND sender_id LIKE $2 || ':%' ON CONFLICT (our_jid, chat_id, sender_id) DO UPDATE SET sender_key=excluded.sender_key ` + hasPNRowsToMigrateQuery = ` + SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id>=$2 AND their_id<$3) + OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id>=$2 AND their_id<$3) + OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id>=$2 AND sender_id<$3) + ` ) func (s *SQLStore) GetSession(ctx context.Context, address string) (session []byte, err error) { @@ -463,9 +468,17 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error if migrated || migrating { return nil } + var hasPNRows bool + err := s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, pnSignal+":", pnSignal+";").Scan(&hasPNRows) + if err != nil { + s.log.Warnf("Failed to check for PN rows to migrate from %s: %v", pnSignal, err) + } else if !hasPNRows { + s.finishPNMigration(pnSignal, nil) + return nil + } var sessionsUpdated, identityKeysUpdated, senderKeysUpdated int64 lidSignal := lid.SignalAddressUser() - err := s.db.DoTxn(ctx, nil, func(ctx context.Context) error { + err = s.db.DoTxn(ctx, nil, func(ctx context.Context) error { res, err := s.db.Exec(ctx, migratePNToLIDSessionsQuery, s.JID, pnSignal, lidSignal) if err != nil { return fmt.Errorf("failed to migrate sessions: %w", err) @@ -506,15 +519,7 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error } return nil }) - s.migratedPNSessionsCacheLock.Lock() - delete(s.migratingPNSessions, pnSignal) - if err == nil { - if s.migratedPNSessionsCache == nil { - s.migratedPNSessionsCache = make(map[string]struct{}) - } - setBoundedCacheEntry(s.migratedPNSessionsCache, pnSignal, struct{}{}, maxMigratedPNEntries) - } - s.migratedPNSessionsCacheLock.Unlock() + s.finishPNMigration(pnSignal, err) if err != nil { return err } @@ -529,6 +534,18 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error return nil } +func (s *SQLStore) finishPNMigration(pnSignal string, migrationErr error) { + s.migratedPNSessionsCacheLock.Lock() + defer s.migratedPNSessionsCacheLock.Unlock() + delete(s.migratingPNSessions, pnSignal) + if migrationErr == nil { + if s.migratedPNSessionsCache == nil { + s.migratedPNSessionsCache = make(map[string]struct{}) + } + setBoundedCacheEntry(s.migratedPNSessionsCache, pnSignal, struct{}{}, maxMigratedPNEntries) + } +} + const ( getLastPreKeyIDQuery = `SELECT MAX(key_id) FROM whatsmeow_pre_keys WHERE jid=$1` insertPreKeyQuery = `INSERT INTO whatsmeow_pre_keys (jid, key_id, key, uploaded) VALUES ($1, $2, $3, $4)` diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index c0a7d66f3..4992e9cec 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -1,9 +1,13 @@ package sqlstore import ( + "context" "fmt" + "regexp" "testing" + "github.com/DATA-DOG/go-sqlmock" + "go.mau.fi/whatsmeow/types" ) @@ -53,3 +57,35 @@ func TestContactCacheIsBounded(t *testing.T) { t.Fatal("new contact was not cached") } } + +func TestMigratePNToLIDSkipsTransactionWhenNoPNRowsExist(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + store := NewSQLStore( + NewWithDB(db, "postgres", nil), + types.NewJID("15550000000", types.DefaultUserServer), + ) + pn := types.NewJID("15551234567", types.DefaultUserServer) + lid := types.NewJID("123456789012345", types.HiddenUserServer) + mock.ExpectQuery(regexp.QuoteMeta(` + SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id>=$2 AND their_id<$3) + OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id>=$2 AND their_id<$3) + OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id>=$2 AND sender_id<$3) + `)). + WithArgs(store.JID, "15551234567:", "15551234567;"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + if err = store.MigratePNToLID(context.Background(), pn, lid); err != nil { + t.Fatal(err) + } + if err = store.MigratePNToLID(context.Background(), pn, lid); err != nil { + t.Fatal(err) + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/store/sqlstore/upgrades/00-latest-schema.sql b/store/sqlstore/upgrades/00-latest-schema.sql index c9db7237e..56a216fc3 100644 --- a/store/sqlstore/upgrades/00-latest-schema.sql +++ b/store/sqlstore/upgrades/00-latest-schema.sql @@ -1,4 +1,4 @@ --- v0 -> v15 (compatible with v8+): Latest schema +-- v0 -> v16 (compatible with v8+): Latest schema CREATE TABLE whatsmeow_device ( jid TEXT PRIMARY KEY, lid TEXT, @@ -66,6 +66,7 @@ CREATE TABLE whatsmeow_sender_keys ( PRIMARY KEY (our_jid, chat_id, sender_id), FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE ); +CREATE INDEX whatsmeow_sender_keys_sender_idx ON whatsmeow_sender_keys (our_jid, sender_id); CREATE TABLE whatsmeow_app_state_sync_keys ( jid TEXT, diff --git a/store/sqlstore/upgrades/16-sender-key-migration-index.sql b/store/sqlstore/upgrades/16-sender-key-migration-index.sql new file mode 100644 index 000000000..fb3cfb9c9 --- /dev/null +++ b/store/sqlstore/upgrades/16-sender-key-migration-index.sql @@ -0,0 +1,2 @@ +-- v16 (compatible with v8+): Index PN-addressed sender keys for LID migration checks +CREATE INDEX whatsmeow_sender_keys_sender_idx ON whatsmeow_sender_keys (our_jid, sender_id); From b858771d9e849fb2880616c36ff2cbcec1381615 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 16:11:05 +0300 Subject: [PATCH 076/163] Keep SQLStore regression dependency-free --- go.mod | 1 - go.sum | 1 - store/sqlstore/store_test.go | 99 +++++++++++++++++++++++++++++------- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index f49a2796b..2661c2d4f 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.0 toolchain go1.26.5 require ( - github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/beeper/argo-go v1.1.2 github.com/coder/websocket v1.8.15 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index f778fff7f..24e9dd851 100644 --- a/go.sum +++ b/go.sum @@ -18,7 +18,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 4992e9cec..84aab7141 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -2,15 +2,71 @@ package sqlstore import ( "context" + "database/sql" + "database/sql/driver" + "errors" "fmt" - "regexp" + "io" + "strings" "testing" - "github.com/DATA-DOG/go-sqlmock" - "go.mau.fi/whatsmeow/types" ) +type pnMigrationTestDB struct { + queries int + begins int + query string + args []driver.NamedValue +} + +type pnMigrationTestConnector struct{ state *pnMigrationTestDB } + +func (c *pnMigrationTestConnector) Connect(context.Context) (driver.Conn, error) { + return &pnMigrationTestConn{state: c.state}, nil +} + +func (*pnMigrationTestConnector) Driver() driver.Driver { return pnMigrationTestDriver{} } + +type pnMigrationTestDriver struct{} + +func (pnMigrationTestDriver) Open(string) (driver.Conn, error) { + return nil, errors.New("use connector") +} + +type pnMigrationTestConn struct{ state *pnMigrationTestDB } + +func (*pnMigrationTestConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} + +func (*pnMigrationTestConn) Close() error { return nil } + +func (c *pnMigrationTestConn) Begin() (driver.Tx, error) { + c.state.begins++ + return nil, errors.New("unexpected transaction") +} + +func (c *pnMigrationTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + c.state.queries++ + c.state.query = query + c.state.args = append([]driver.NamedValue(nil), args...) + return &pnMigrationTestRows{}, nil +} + +type pnMigrationTestRows struct{ read bool } + +func (*pnMigrationTestRows) Columns() []string { return []string{"exists"} } +func (*pnMigrationTestRows) Close() error { return nil } +func (r *pnMigrationTestRows) Next(values []driver.Value) error { + if r.read { + return io.EOF + } + r.read = true + values[0] = false + return nil +} + func TestNewSQLStoreDefersCaches(t *testing.T) { store := NewSQLStore(nil, types.JID{User: "123", Server: types.DefaultUserServer}) if store.contactCache != nil || store.identityCache != nil || store.migratedPNSessionsCache != nil || store.migratingPNSessions != nil { @@ -59,10 +115,8 @@ func TestContactCacheIsBounded(t *testing.T) { } func TestMigratePNToLIDSkipsTransactionWhenNoPNRowsExist(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatal(err) - } + state := &pnMigrationTestDB{} + db := sql.OpenDB(&pnMigrationTestConnector{state: state}) t.Cleanup(func() { _ = db.Close() }) store := NewSQLStore( @@ -71,21 +125,28 @@ func TestMigratePNToLIDSkipsTransactionWhenNoPNRowsExist(t *testing.T) { ) pn := types.NewJID("15551234567", types.DefaultUserServer) lid := types.NewJID("123456789012345", types.HiddenUserServer) - mock.ExpectQuery(regexp.QuoteMeta(` - SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id>=$2 AND their_id<$3) - OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id>=$2 AND their_id<$3) - OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id>=$2 AND sender_id<$3) - `)). - WithArgs(store.JID, "15551234567:", "15551234567;"). - WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) - - if err = store.MigratePNToLID(context.Background(), pn, lid); err != nil { + + if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil { t.Fatal(err) } - if err = store.MigratePNToLID(context.Background(), pn, lid); err != nil { + if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil { t.Fatal(err) } - if err = mock.ExpectationsWereMet(); err != nil { - t.Fatal(err) + if state.queries != 1 || state.begins != 0 { + t.Fatalf("unexpected database work: %d queries, %d transactions", state.queries, state.begins) + } + for _, table := range []string{"whatsmeow_sessions", "whatsmeow_identity_keys", "whatsmeow_sender_keys"} { + if !strings.Contains(state.query, table) { + t.Fatalf("existence query did not cover %s", table) + } + } + wantArgs := []string{store.JID, "15551234567:", "15551234567;"} + if len(state.args) != len(wantArgs) { + t.Fatalf("unexpected existence query argument count %d", len(state.args)) + } + for i, want := range wantArgs { + if got := fmt.Sprint(state.args[i].Value); got != want { + t.Fatalf("existence query argument %d = %q, want %q", i, got, want) + } } } From 62f83afee2f00bc08a1d9c479ff8d6b7c9979fff Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:07:48 +0300 Subject: [PATCH 077/163] fix: keep PN migration preflight race-safe --- store/sqlstore/store.go | 16 ++++++++-------- store/sqlstore/store_test.go | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 5b459d9d1..39d5156ef 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -254,9 +254,9 @@ const ( ON CONFLICT (our_jid, chat_id, sender_id) DO UPDATE SET sender_key=excluded.sender_key ` hasPNRowsToMigrateQuery = ` - SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id>=$2 AND their_id<$3) - OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id>=$2 AND their_id<$3) - OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id>=$2 AND sender_id<$3) + SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id LIKE $2 || ':%') + OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2 || ':%') + OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id LIKE $2 || ':%') ` ) @@ -469,11 +469,11 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error return nil } var hasPNRows bool - err := s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, pnSignal+":", pnSignal+";").Scan(&hasPNRows) + err := s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, pnSignal).Scan(&hasPNRows) if err != nil { s.log.Warnf("Failed to check for PN rows to migrate from %s: %v", pnSignal, err) } else if !hasPNRows { - s.finishPNMigration(pnSignal, nil) + s.finishPNMigration(pnSignal, false) return nil } var sessionsUpdated, identityKeysUpdated, senderKeysUpdated int64 @@ -519,7 +519,7 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error } return nil }) - s.finishPNMigration(pnSignal, err) + s.finishPNMigration(pnSignal, err == nil) if err != nil { return err } @@ -534,11 +534,11 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error return nil } -func (s *SQLStore) finishPNMigration(pnSignal string, migrationErr error) { +func (s *SQLStore) finishPNMigration(pnSignal string, markMigrated bool) { s.migratedPNSessionsCacheLock.Lock() defer s.migratedPNSessionsCacheLock.Unlock() delete(s.migratingPNSessions, pnSignal) - if migrationErr == nil { + if markMigrated { if s.migratedPNSessionsCache == nil { s.migratedPNSessionsCache = make(map[string]struct{}) } diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 84aab7141..1ba6807a1 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -114,7 +114,7 @@ func TestContactCacheIsBounded(t *testing.T) { } } -func TestMigratePNToLIDSkipsTransactionWhenNoPNRowsExist(t *testing.T) { +func TestMigratePNToLIDDoesNotCacheEmptyPreflight(t *testing.T) { state := &pnMigrationTestDB{} db := sql.OpenDB(&pnMigrationTestConnector{state: state}) t.Cleanup(func() { _ = db.Close() }) @@ -132,7 +132,7 @@ func TestMigratePNToLIDSkipsTransactionWhenNoPNRowsExist(t *testing.T) { if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil { t.Fatal(err) } - if state.queries != 1 || state.begins != 0 { + if state.queries != 2 || state.begins != 0 { t.Fatalf("unexpected database work: %d queries, %d transactions", state.queries, state.begins) } for _, table := range []string{"whatsmeow_sessions", "whatsmeow_identity_keys", "whatsmeow_sender_keys"} { @@ -140,7 +140,7 @@ func TestMigratePNToLIDSkipsTransactionWhenNoPNRowsExist(t *testing.T) { t.Fatalf("existence query did not cover %s", table) } } - wantArgs := []string{store.JID, "15551234567:", "15551234567;"} + wantArgs := []string{store.JID, "15551234567"} if len(state.args) != len(wantArgs) { t.Fatalf("unexpected existence query argument count %d", len(state.args)) } From dee78d0a33e3daed8ede2dc027726030b0299204 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:44:39 +0300 Subject: [PATCH 078/163] fix: bound empty PN migration queries --- store/sqlstore/store.go | 24 +++++++++++++++++++++--- store/sqlstore/store_test.go | 16 +++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 39d5156ef..542679747 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -77,6 +77,7 @@ type SQLStore struct { identityCacheLock sync.RWMutex migratedPNSessionsCache map[string]struct{} + emptyPNMigrationCache map[string]time.Time migratingPNSessions map[string]struct{} migratedPNSessionsCacheLock sync.Mutex } @@ -90,6 +91,7 @@ const ( maxContactCacheEntries = 256 maxIdentityCacheEntries = 2048 maxMigratedPNEntries = 1024 + emptyPNMigrationTTL = time.Minute ) func setBoundedCacheEntry[K comparable, V any](cache map[K]V, key K, value V, limit int) { @@ -458,14 +460,19 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error s.migratedPNSessionsCacheLock.Lock() _, migrated := s.migratedPNSessionsCache[pnSignal] _, migrating := s.migratingPNSessions[pnSignal] - if !migrated && !migrating { + emptyUntil, empty := s.emptyPNMigrationCache[pnSignal] + if empty && !time.Now().Before(emptyUntil) { + delete(s.emptyPNMigrationCache, pnSignal) + empty = false + } + if !migrated && !migrating && !empty { if s.migratingPNSessions == nil { s.migratingPNSessions = make(map[string]struct{}) } s.migratingPNSessions[pnSignal] = struct{}{} } s.migratedPNSessionsCacheLock.Unlock() - if migrated || migrating { + if migrated || migrating || empty { return nil } var hasPNRows bool @@ -473,7 +480,7 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error if err != nil { s.log.Warnf("Failed to check for PN rows to migrate from %s: %v", pnSignal, err) } else if !hasPNRows { - s.finishPNMigration(pnSignal, false) + s.finishEmptyPNMigration(pnSignal) return nil } var sessionsUpdated, identityKeysUpdated, senderKeysUpdated int64 @@ -538,6 +545,7 @@ func (s *SQLStore) finishPNMigration(pnSignal string, markMigrated bool) { s.migratedPNSessionsCacheLock.Lock() defer s.migratedPNSessionsCacheLock.Unlock() delete(s.migratingPNSessions, pnSignal) + delete(s.emptyPNMigrationCache, pnSignal) if markMigrated { if s.migratedPNSessionsCache == nil { s.migratedPNSessionsCache = make(map[string]struct{}) @@ -546,6 +554,16 @@ func (s *SQLStore) finishPNMigration(pnSignal string, markMigrated bool) { } } +func (s *SQLStore) finishEmptyPNMigration(pnSignal string) { + s.migratedPNSessionsCacheLock.Lock() + defer s.migratedPNSessionsCacheLock.Unlock() + delete(s.migratingPNSessions, pnSignal) + if s.emptyPNMigrationCache == nil { + s.emptyPNMigrationCache = make(map[string]time.Time) + } + setBoundedCacheEntry(s.emptyPNMigrationCache, pnSignal, time.Now().Add(emptyPNMigrationTTL), maxMigratedPNEntries) +} + const ( getLastPreKeyIDQuery = `SELECT MAX(key_id) FROM whatsmeow_pre_keys WHERE jid=$1` insertPreKeyQuery = `INSERT INTO whatsmeow_pre_keys (jid, key_id, key, uploaded) VALUES ($1, $2, $3, $4)` diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 1ba6807a1..4506b0853 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -9,6 +9,7 @@ import ( "io" "strings" "testing" + "time" "go.mau.fi/whatsmeow/types" ) @@ -69,7 +70,7 @@ func (r *pnMigrationTestRows) Next(values []driver.Value) error { func TestNewSQLStoreDefersCaches(t *testing.T) { store := NewSQLStore(nil, types.JID{User: "123", Server: types.DefaultUserServer}) - if store.contactCache != nil || store.identityCache != nil || store.migratedPNSessionsCache != nil || store.migratingPNSessions != nil { + if store.contactCache != nil || store.identityCache != nil || store.migratedPNSessionsCache != nil || store.emptyPNMigrationCache != nil || store.migratingPNSessions != nil { t.Fatal("SQL store allocated caches before use") } } @@ -114,7 +115,7 @@ func TestContactCacheIsBounded(t *testing.T) { } } -func TestMigratePNToLIDDoesNotCacheEmptyPreflight(t *testing.T) { +func TestMigratePNToLIDCachesEmptyPreflightTemporarily(t *testing.T) { state := &pnMigrationTestDB{} db := sql.OpenDB(&pnMigrationTestConnector{state: state}) t.Cleanup(func() { _ = db.Close() }) @@ -132,9 +133,18 @@ func TestMigratePNToLIDDoesNotCacheEmptyPreflight(t *testing.T) { if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil { t.Fatal(err) } - if state.queries != 2 || state.begins != 0 { + if state.queries != 1 || state.begins != 0 { t.Fatalf("unexpected database work: %d queries, %d transactions", state.queries, state.begins) } + store.migratedPNSessionsCacheLock.Lock() + store.emptyPNMigrationCache[pn.SignalAddressUser()] = time.Now().Add(-time.Second) + store.migratedPNSessionsCacheLock.Unlock() + if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil { + t.Fatal(err) + } + if state.queries != 2 { + t.Fatalf("expired empty migration cache suppressed a query: %d", state.queries) + } for _, table := range []string{"whatsmeow_sessions", "whatsmeow_identity_keys", "whatsmeow_sender_keys"} { if !strings.Contains(state.query, table) { t.Fatalf("existence query did not cover %s", table) From fdc31f4ffb92d6ebdfd4830f0ae2f43ec24a48e4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:08:31 +0300 Subject: [PATCH 079/163] perf(sqlstore): bind PN migration prefix patterns --- store/sqlstore/store.go | 8 ++++---- store/sqlstore/store_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 542679747..bfd057c67 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -256,9 +256,9 @@ const ( ON CONFLICT (our_jid, chat_id, sender_id) DO UPDATE SET sender_key=excluded.sender_key ` hasPNRowsToMigrateQuery = ` - SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id LIKE $2 || ':%') - OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2 || ':%') - OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id LIKE $2 || ':%') + SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id LIKE $2) + OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2) + OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id LIKE $2) ` ) @@ -476,7 +476,7 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error return nil } var hasPNRows bool - err := s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, pnSignal).Scan(&hasPNRows) + err := s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, pnSignal+":%").Scan(&hasPNRows) if err != nil { s.log.Warnf("Failed to check for PN rows to migrate from %s: %v", pnSignal, err) } else if !hasPNRows { diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 4506b0853..763e4c01b 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -150,7 +150,7 @@ func TestMigratePNToLIDCachesEmptyPreflightTemporarily(t *testing.T) { t.Fatalf("existence query did not cover %s", table) } } - wantArgs := []string{store.JID, "15551234567"} + wantArgs := []string{store.JID, "15551234567:%"} if len(state.args) != len(wantArgs) { t.Fatalf("unexpected existence query argument count %d", len(state.args)) } From f9ddf7ba94acf63d51efe9f38531781fc0db1be4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:23:06 +0300 Subject: [PATCH 080/163] perf(sqlstore): use SQLite prefix ranges --- store/sqlstore/store.go | 12 +++++++++++- store/sqlstore/store_test.go | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index bfd057c67..c164e0ba6 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -260,6 +260,11 @@ const ( OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2) OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id LIKE $2) ` + hasPNRowsToMigrateSQLiteQuery = ` + SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3) + OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3) + OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id >= $2 AND sender_id < $3) + ` ) func (s *SQLStore) GetSession(ctx context.Context, address string) (session []byte, err error) { @@ -476,7 +481,12 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error return nil } var hasPNRows bool - err := s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, pnSignal+":%").Scan(&hasPNRows) + var err error + if s.db.Dialect == dbutil.SQLite { + err = s.db.QueryRow(ctx, hasPNRowsToMigrateSQLiteQuery, s.JID, pnSignal+":", pnSignal+";").Scan(&hasPNRows) + } else { + err = s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, pnSignal+":%").Scan(&hasPNRows) + } if err != nil { s.log.Warnf("Failed to check for PN rows to migrate from %s: %v", pnSignal, err) } else if !hasPNRows { diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 763e4c01b..6a63f458a 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -160,3 +160,25 @@ func TestMigratePNToLIDCachesEmptyPreflightTemporarily(t *testing.T) { } } } + +func TestMigratePNToLIDUsesSQLitePrefixRange(t *testing.T) { + state := &pnMigrationTestDB{} + db := sql.OpenDB(&pnMigrationTestConnector{state: state}) + t.Cleanup(func() { _ = db.Close() }) + store := NewSQLStore(NewWithDB(db, "sqlite3", nil), types.NewJID("15550000000", types.DefaultUserServer)) + pn := types.NewJID("15551234567", types.DefaultUserServer) + lid := types.NewJID("123456789012345", types.HiddenUserServer) + + if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil { + t.Fatal(err) + } + wantArgs := []string{store.JID, "15551234567:", "15551234567;"} + if len(state.args) != len(wantArgs) { + t.Fatalf("SQLite preflight argument count = %d, want %d", len(state.args), len(wantArgs)) + } + for index, want := range wantArgs { + if got := fmt.Sprint(state.args[index].Value); got != want { + t.Fatalf("SQLite preflight argument %d = %q, want %q", index, got, want) + } + } +} From 2f25c8bb7b5b063bb9179792dd957307395cf2d5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 11:12:51 +0300 Subject: [PATCH 081/163] perf(sqlstore): index postgres prefix lookups --- store/sqlstore/upgrades/00-latest-schema.sql | 8 +++++++- .../sqlstore/upgrades/17-pn-migration-pattern-indexes.sql | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql diff --git a/store/sqlstore/upgrades/00-latest-schema.sql b/store/sqlstore/upgrades/00-latest-schema.sql index 56a216fc3..a4820e076 100644 --- a/store/sqlstore/upgrades/00-latest-schema.sql +++ b/store/sqlstore/upgrades/00-latest-schema.sql @@ -1,4 +1,4 @@ --- v0 -> v16 (compatible with v8+): Latest schema +-- v0 -> v17 (compatible with v8+): Latest schema CREATE TABLE whatsmeow_device ( jid TEXT PRIMARY KEY, lid TEXT, @@ -37,6 +37,8 @@ CREATE TABLE whatsmeow_identity_keys ( PRIMARY KEY (our_jid, their_id), FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE ); +-- only: postgres +CREATE INDEX whatsmeow_identity_keys_their_pattern_idx ON whatsmeow_identity_keys (our_jid, their_id text_pattern_ops); CREATE TABLE whatsmeow_pre_keys ( jid TEXT, @@ -56,6 +58,8 @@ CREATE TABLE whatsmeow_sessions ( PRIMARY KEY (our_jid, their_id), FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE ); +-- only: postgres +CREATE INDEX whatsmeow_sessions_their_pattern_idx ON whatsmeow_sessions (our_jid, their_id text_pattern_ops); CREATE TABLE whatsmeow_sender_keys ( our_jid TEXT, @@ -67,6 +71,8 @@ CREATE TABLE whatsmeow_sender_keys ( FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE ); CREATE INDEX whatsmeow_sender_keys_sender_idx ON whatsmeow_sender_keys (our_jid, sender_id); +-- only: postgres +CREATE INDEX whatsmeow_sender_keys_sender_pattern_idx ON whatsmeow_sender_keys (our_jid, sender_id text_pattern_ops); CREATE TABLE whatsmeow_app_state_sync_keys ( jid TEXT, diff --git a/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql b/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql new file mode 100644 index 000000000..8a1575eeb --- /dev/null +++ b/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql @@ -0,0 +1,7 @@ +-- v17 (compatible with v8+): Index PN migration prefix lookups on PostgreSQL +-- only: postgres +CREATE INDEX whatsmeow_identity_keys_their_pattern_idx ON whatsmeow_identity_keys (our_jid, their_id text_pattern_ops); +-- only: postgres +CREATE INDEX whatsmeow_sessions_their_pattern_idx ON whatsmeow_sessions (our_jid, their_id text_pattern_ops); +-- only: postgres +CREATE INDEX whatsmeow_sender_keys_sender_pattern_idx ON whatsmeow_sender_keys (our_jid, sender_id text_pattern_ops); From a56740b950bd52192e534330f072aceee99cfe7e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 16:17:34 +0300 Subject: [PATCH 082/163] Harden malformed binary node decoding --- binary/decoder.go | 43 ++++++++++++++++----- binary/decoder_test.go | 86 ++++++++++++++++++++++++++++++++++++++++++ binary/unpack.go | 3 ++ 3 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 binary/decoder_test.go diff --git a/binary/decoder.go b/binary/decoder.go index 476f87bd1..8396f0fd1 100644 --- a/binary/decoder.go +++ b/binary/decoder.go @@ -109,7 +109,7 @@ func (r *binaryDecoder) readPacked8(tag int) (string, error) { } ret := build.String() - if startByte>>7 != 0 { + if startByte>>7 != 0 && len(ret) > 0 { ret = ret[:len(ret)-1] } return ret, nil @@ -232,10 +232,19 @@ func (r *binaryDecoder) readJIDPair() (any, error) { return nil, err } else if server == nil { return nil, ErrInvalidJIDType - } else if user == nil { - return types.NewJID("", server.(string)), nil } - return types.NewJID(user.(string), server.(string)), nil + serverStr, ok := server.(string) + if !ok { + return nil, ErrInvalidJIDType + } + if user == nil { + return types.NewJID("", serverStr), nil + } + userStr, ok := user.(string) + if !ok { + return nil, ErrInvalidJIDType + } + return types.NewJID(userStr, serverStr), nil } func (r *binaryDecoder) readInteropJID() (any, error) { @@ -257,8 +266,12 @@ func (r *binaryDecoder) readInteropJID() (any, error) { } else if server != types.InteropServer { return nil, fmt.Errorf("%w: expected %q, got %q", ErrInvalidJIDType, types.InteropServer, server) } + userStr, ok := user.(string) + if !ok { + return nil, ErrInvalidJIDType + } return types.JID{ - User: user.(string), + User: userStr, Device: uint16(device), Integrator: uint16(integrator), Server: types.InteropServer, @@ -280,10 +293,14 @@ func (r *binaryDecoder) readFBJID() (any, error) { } else if server != types.MessengerServer { return nil, fmt.Errorf("%w: expected %q, got %q", ErrInvalidJIDType, types.MessengerServer, server) } + userStr, ok := user.(string) + if !ok { + return nil, ErrInvalidJIDType + } return types.JID{ - User: user.(string), + User: userStr, Device: uint16(device), - Server: server.(string), + Server: types.MessengerServer, }, nil } @@ -300,7 +317,11 @@ func (r *binaryDecoder) readADJID() (any, error) { if err != nil { return nil, err } - return types.NewADJID(user.(string), agent, device), nil + userStr, ok := user.(string) + if !ok { + return nil, ErrInvalidJIDType + } + return types.NewADJID(userStr, agent, device), nil } func (r *binaryDecoder) readAttributes(n int) (Attrs, error) { @@ -365,7 +386,11 @@ func (r *binaryDecoder) readNode() (*Node, error) { if err != nil { return nil, err } - ret.Tag = rawDesc.(string) + tag, ok := rawDesc.(string) + if !ok { + return nil, ErrInvalidNode + } + ret.Tag = tag if listSize == 0 || ret.Tag == "" { return nil, ErrInvalidNode } diff --git a/binary/decoder_test.go b/binary/decoder_test.go new file mode 100644 index 000000000..89a9a2ff4 --- /dev/null +++ b/binary/decoder_test.go @@ -0,0 +1,86 @@ +package binary_test + +import ( + "reflect" + "testing" + + "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +func TestMarshalUnmarshalRoundTrip(t *testing.T) { + want := binary.Node{ + Tag: "iq", + Attrs: binary.Attrs{ + "id": "abc", + "from": types.NewJID("12345", types.DefaultUserServer), + }, + Content: []binary.Node{{Tag: "body", Content: []byte("hi")}}, + } + marshaled, err := binary.Marshal(want) + if err != nil { + t.Fatal(err) + } + unpacked, err := binary.Unpack(marshaled) + if err != nil { + t.Fatal(err) + } + got, err := binary.Unmarshal(unpacked) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(*got, want) { + t.Fatalf("round trip mismatch: got %#v, want %#v", *got, want) + } +} + +func TestUnmarshalRejectsMalformedStringTokens(t *testing.T) { + tests := map[string][]byte{ + "nil node tag": {248, 1, 0}, + "empty packed tag": {248, 1, 255, 128}, + "JID user list": {248, 2, 252, 1, 97, 250, 248, 0, 252, 1, 115}, + "JID server list": {248, 2, 252, 1, 97, 250, 0, 248, 0}, + "AD JID user list": {248, 2, 252, 1, 97, 247, 1, 1, 248, 0}, + "FB JID user list": {248, 2, 252, 1, 97, 246, 248, 0, 0, 0, 252, 4, 109, 115, 103, 114}, + "interop user list": {248, 2, 252, 1, 97, 245, 248, 0, 0, 0, 0, 0, 252, 7, + 105, 110, 116, 101, 114, 111, 112}, + } + for name, input := range tests { + t.Run(name, func(t *testing.T) { + if _, err := binary.Unmarshal(input); err == nil { + t.Fatal("expected malformed node error") + } + }) + } +} + +func TestUnpackRejectsEmptyInput(t *testing.T) { + for _, input := range [][]byte{nil, {}} { + if _, err := binary.Unpack(input); err == nil { + t.Fatal("expected empty input error") + } + } +} + +func FuzzUnmarshal(f *testing.F) { + for _, input := range [][]byte{ + {248, 1, 0}, + {248, 1, 255, 128}, + {248, 2, 252, 1, 97, 250, 248, 0, 252, 1, 115}, + {}, + } { + f.Add(input) + } + f.Fuzz(func(t *testing.T, input []byte) { + _, _ = binary.Unmarshal(input) + }) +} + +func FuzzUnpack(f *testing.F) { + for _, input := range [][]byte{{}, {0}, {1}, {2, 1, 2, 3}} { + f.Add(input) + } + f.Fuzz(func(t *testing.T, input []byte) { + _, _ = binary.Unpack(input) + }) +} diff --git a/binary/unpack.go b/binary/unpack.go index a4557c57a..3e4c14a68 100644 --- a/binary/unpack.go +++ b/binary/unpack.go @@ -19,6 +19,9 @@ import ( // (without the first byte). There's currently no corresponding Pack function because Marshal // already returns the data with a leading zero (i.e. not compressed). func Unpack(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, io.ErrUnexpectedEOF + } dataType, data := data[0], data[1:] if 2&dataType > 0 { if decompressor, err := zlib.NewReader(bytes.NewReader(data)); err != nil { From 399be550cda4c291a90aee43e79aa11e8739a21b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:08:44 +0300 Subject: [PATCH 083/163] fix: reject empty odd packed strings --- binary/decoder.go | 6 +++++- binary/decoder_test.go | 13 +++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/binary/decoder.go b/binary/decoder.go index 8396f0fd1..436e19772 100644 --- a/binary/decoder.go +++ b/binary/decoder.go @@ -1,6 +1,7 @@ package binary import ( + "errors" "fmt" "io" "strings" @@ -109,7 +110,10 @@ func (r *binaryDecoder) readPacked8(tag int) (string, error) { } ret := build.String() - if startByte>>7 != 0 && len(ret) > 0 { + if startByte>>7 != 0 { + if len(ret) == 0 { + return "", errors.New("packed string has odd flag without data") + } ret = ret[:len(ret)-1] } return ret, nil diff --git a/binary/decoder_test.go b/binary/decoder_test.go index 89a9a2ff4..16370c7c0 100644 --- a/binary/decoder_test.go +++ b/binary/decoder_test.go @@ -36,12 +36,13 @@ func TestMarshalUnmarshalRoundTrip(t *testing.T) { func TestUnmarshalRejectsMalformedStringTokens(t *testing.T) { tests := map[string][]byte{ - "nil node tag": {248, 1, 0}, - "empty packed tag": {248, 1, 255, 128}, - "JID user list": {248, 2, 252, 1, 97, 250, 248, 0, 252, 1, 115}, - "JID server list": {248, 2, 252, 1, 97, 250, 0, 248, 0}, - "AD JID user list": {248, 2, 252, 1, 97, 247, 1, 1, 248, 0}, - "FB JID user list": {248, 2, 252, 1, 97, 246, 248, 0, 0, 0, 252, 4, 109, 115, 103, 114}, + "nil node tag": {248, 1, 0}, + "empty packed tag": {248, 1, 255, 128}, + "empty packed attribute": {248, 3, 252, 1, 97, 255, 128, 252, 1, 120}, + "JID user list": {248, 2, 252, 1, 97, 250, 248, 0, 252, 1, 115}, + "JID server list": {248, 2, 252, 1, 97, 250, 0, 248, 0}, + "AD JID user list": {248, 2, 252, 1, 97, 247, 1, 1, 248, 0}, + "FB JID user list": {248, 2, 252, 1, 97, 246, 248, 0, 0, 0, 252, 4, 109, 115, 103, 114}, "interop user list": {248, 2, 252, 1, 97, 245, 248, 0, 0, 0, 0, 0, 252, 7, 105, 110, 116, 101, 114, 111, 112}, } From 1908453ab88fd0df263a79f420ce7d669e20dd9d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 16:22:05 +0300 Subject: [PATCH 084/163] Serialize device save and delete --- store/store.go | 7 ++++ store/store_test.go | 88 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 store/store_test.go diff --git a/store/store.go b/store/store.go index 6ed41c6ce..39604a294 100644 --- a/store/store.go +++ b/store/store.go @@ -10,6 +10,7 @@ package store import ( "context" "errors" + "sync" "time" "github.com/google/uuid" @@ -252,6 +253,8 @@ type Device struct { EventBuffer EventBuffer LIDs LIDStore Container DeviceContainer + + saveDeleteLock sync.Mutex } func (device *Device) GetJID() types.JID { @@ -275,6 +278,8 @@ func (device *Device) GetLID() types.JID { var ErrDeviceDeleted = errors.New("invalid use of deleted device") func (device *Device) Save(ctx context.Context) error { + device.saveDeleteLock.Lock() + defer device.saveDeleteLock.Unlock() if device.Deleted { return ErrDeviceDeleted } @@ -282,6 +287,8 @@ func (device *Device) Save(ctx context.Context) error { } func (device *Device) Delete(ctx context.Context) error { + device.saveDeleteLock.Lock() + defer device.saveDeleteLock.Unlock() if device.Deleted { return nil } diff --git a/store/store_test.go b/store/store_test.go new file mode 100644 index 000000000..74b311558 --- /dev/null +++ b/store/store_test.go @@ -0,0 +1,88 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" + + "go.mau.fi/whatsmeow/types" +) + +type blockingDeviceContainer struct { + putStarted chan struct{} + allowPut chan struct{} + deleteStarted chan struct{} +} + +func (c *blockingDeviceContainer) PutDevice(_ context.Context, device *Device) error { + close(c.putStarted) + <-c.allowPut + if device.ID == nil { + return errors.New("device ID cleared during save") + } + return nil +} + +func (c *blockingDeviceContainer) DeleteDevice(context.Context, *Device) error { + close(c.deleteStarted) + return nil +} + +func TestDeviceDeleteWaitsForSave(t *testing.T) { + container := &blockingDeviceContainer{ + putStarted: make(chan struct{}), + allowPut: make(chan struct{}), + deleteStarted: make(chan struct{}), + } + id := types.NewJID("15551234567", types.DefaultUserServer) + device := &Device{ID: &id, Container: container} + saveDone := make(chan error, 1) + deleteDone := make(chan error, 1) + + go func() { saveDone <- device.Save(context.Background()) }() + <-container.putStarted + go func() { deleteDone <- device.Delete(context.Background()) }() + + select { + case <-container.deleteStarted: + close(container.allowPut) + <-saveDone + <-deleteDone + t.Fatal("delete entered the container while save was active") + case <-time.After(50 * time.Millisecond): + } + + close(container.allowPut) + if err := <-saveDone; err != nil { + t.Fatal(err) + } + if err := <-deleteDone; err != nil { + t.Fatal(err) + } + if !device.Deleted || device.ID != nil { + t.Fatalf("device was not deleted: deleted=%t id=%v", device.Deleted, device.ID) + } +} + +func TestDeviceSaveAfterDeleteFailsWithoutContainerWrite(t *testing.T) { + container := &blockingDeviceContainer{ + putStarted: make(chan struct{}), + allowPut: make(chan struct{}), + deleteStarted: make(chan struct{}), + } + close(container.allowPut) + id := types.NewJID("15551234567", types.DefaultUserServer) + device := &Device{ID: &id, Container: container} + if err := device.Delete(context.Background()); err != nil { + t.Fatal(err) + } + if err := device.Save(context.Background()); !errors.Is(err, ErrDeviceDeleted) { + t.Fatalf("save error = %v, want %v", err, ErrDeviceDeleted) + } + select { + case <-container.putStarted: + t.Fatal("save wrote to the container after delete") + default: + } +} From c784273d60c4d4b0f7c8176a2e10bc3fae533ae6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:03:02 +0300 Subject: [PATCH 085/163] fix: make device persistence waits cancelable --- store/store.go | 32 +++++++++++++++++++++++++++----- store/store_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/store/store.go b/store/store.go index 39604a294..94155d0a7 100644 --- a/store/store.go +++ b/store/store.go @@ -254,7 +254,8 @@ type Device struct { LIDs LIDStore Container DeviceContainer - saveDeleteLock sync.Mutex + saveDeleteLockInit sync.Once + saveDeleteLock chan struct{} } func (device *Device) GetJID() types.JID { @@ -277,9 +278,28 @@ func (device *Device) GetLID() types.JID { var ErrDeviceDeleted = errors.New("invalid use of deleted device") +func (device *Device) lockSaveDelete(ctx context.Context) error { + device.saveDeleteLockInit.Do(func() { + device.saveDeleteLock = make(chan struct{}, 1) + device.saveDeleteLock <- struct{}{} + }) + select { + case <-device.saveDeleteLock: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (device *Device) unlockSaveDelete() { + device.saveDeleteLock <- struct{}{} +} + func (device *Device) Save(ctx context.Context) error { - device.saveDeleteLock.Lock() - defer device.saveDeleteLock.Unlock() + if err := device.lockSaveDelete(ctx); err != nil { + return err + } + defer device.unlockSaveDelete() if device.Deleted { return ErrDeviceDeleted } @@ -287,8 +307,10 @@ func (device *Device) Save(ctx context.Context) error { } func (device *Device) Delete(ctx context.Context) error { - device.saveDeleteLock.Lock() - defer device.saveDeleteLock.Unlock() + if err := device.lockSaveDelete(ctx); err != nil { + return err + } + defer device.unlockSaveDelete() if device.Deleted { return nil } diff --git a/store/store_test.go b/store/store_test.go index 74b311558..45b3ae55c 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -65,6 +65,40 @@ func TestDeviceDeleteWaitsForSave(t *testing.T) { } } +func TestDeviceDeleteWaitObservesCancellation(t *testing.T) { + container := &blockingDeviceContainer{ + putStarted: make(chan struct{}), + allowPut: make(chan struct{}), + deleteStarted: make(chan struct{}), + } + id := types.NewJID("15551234567", types.DefaultUserServer) + device := &Device{ID: &id, Container: container} + saveDone := make(chan error, 1) + go func() { saveDone <- device.Save(context.Background()) }() + <-container.putStarted + ctx, cancel := context.WithCancel(context.Background()) + cancel() + deleteDone := make(chan error, 1) + go func() { deleteDone <- device.Delete(ctx) }() + select { + case err := <-deleteDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("delete error = %v, want context canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled delete remained blocked") + } + select { + case <-container.deleteStarted: + t.Fatal("canceled delete entered the container") + default: + } + close(container.allowPut) + if err := <-saveDone; err != nil { + t.Fatal(err) + } +} + func TestDeviceSaveAfterDeleteFailsWithoutContainerWrite(t *testing.T) { container := &blockingDeviceContainer{ putStarted: make(chan struct{}), From 058bf9edbb0137b5c6b1fe05401e9d4a425512b6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 04:27:36 +0300 Subject: [PATCH 086/163] fix: recheck device lock cancellation --- store/store.go | 4 ++++ store/store_test.go | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/store/store.go b/store/store.go index 94155d0a7..8b881de95 100644 --- a/store/store.go +++ b/store/store.go @@ -285,6 +285,10 @@ func (device *Device) lockSaveDelete(ctx context.Context) error { }) select { case <-device.saveDeleteLock: + if err := ctx.Err(); err != nil { + device.unlockSaveDelete() + return err + } return nil case <-ctx.Done(): return ctx.Err() diff --git a/store/store_test.go b/store/store_test.go index 45b3ae55c..5999d68c6 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -99,6 +99,22 @@ func TestDeviceDeleteWaitObservesCancellation(t *testing.T) { } } +func TestDeviceLockRejectsCanceledContextWhenAvailable(t *testing.T) { + device := &Device{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + for range 100 { + err := device.lockSaveDelete(ctx) + if err == nil { + device.unlockSaveDelete() + t.Fatal("canceled context acquired the save/delete lock") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("lock error = %v, want context canceled", err) + } + } +} + func TestDeviceSaveAfterDeleteFailsWithoutContainerWrite(t *testing.T) { container := &blockingDeviceContainer{ putStarted: make(chan struct{}), From 6399c0a1aafb0fcbe9342f4e02bcadb72e4f6322 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 16:26:06 +0300 Subject: [PATCH 087/163] Expose participant hash mismatches --- send.go | 10 +++++++++- send_test.go | 24 ++++++++++++++++++++++++ sendfb.go | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/send.go b/send.go index 4854a01ca..0ba17ac0e 100644 --- a/send.go +++ b/send.go @@ -126,6 +126,14 @@ type SendResponse struct { // The identity the message was sent with (LID or PN) // This is currently not reliable in all cases. Sender types.JID + + // PHashMismatch indicates the server acknowledged a different participant list hash. + PHashMismatch bool +} + +func setParticipantHashMismatch(resp *SendResponse, sent, acknowledged string) bool { + resp.PHashMismatch = acknowledged != "" && sent != acknowledged + return resp.PHashMismatch } // SendRequestExtra contains the optional parameters for SendMessage. @@ -452,7 +460,7 @@ func (cli *Client) SendMessage(ctx context.Context, to types.JID, message *waE2E err = fmt.Errorf("%w %d", ErrServerReturnedError, errorCode) } expectedPHash := ag.OptionalString("phash") - if len(expectedPHash) > 0 && phash != expectedPHash { + if setParticipantHashMismatch(&resp, phash, expectedPHash) { cli.Log.Warnf("Server returned different participant list hash (%s != %s) when sending to %s. Some devices may not have received the message.", phash, expectedPHash, to) switch to.Server { case types.GroupServer: diff --git a/send_test.go b/send_test.go index 4b43aa33f..272d9261c 100644 --- a/send_test.go +++ b/send_test.go @@ -22,3 +22,27 @@ func TestButtonAndListResponsesDoNotRequestBusinessMetadata(t *testing.T) { }) } } + +func TestSetParticipantHashMismatch(t *testing.T) { + tests := []struct { + name string + sent string + ack string + want bool + }{ + {name: "matching", sent: "same", ack: "same"}, + {name: "missing acknowledgement hash", sent: "sent"}, + {name: "mismatch", sent: "old", ack: "new", want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp := SendResponse{} + if got := setParticipantHashMismatch(&resp, tc.sent, tc.ack); got != tc.want { + t.Fatalf("mismatch = %t, want %t", got, tc.want) + } + if resp.PHashMismatch != tc.want { + t.Fatalf("response mismatch = %t, want %t", resp.PHashMismatch, tc.want) + } + }) + } +} diff --git a/sendfb.go b/sendfb.go index 3c21c6015..fb271908c 100644 --- a/sendfb.go +++ b/sendfb.go @@ -198,7 +198,7 @@ func (cli *Client) SendFBMessage( err = fmt.Errorf("%w %d", ErrServerReturnedError, errorCode) } expectedPHash := ag.OptionalString("phash") - if len(expectedPHash) > 0 && phash != expectedPHash { + if setParticipantHashMismatch(&resp, phash, expectedPHash) { cli.Log.Warnf("Server returned different participant list hash when sending to %s. Some devices may not have received the message.", to) // TODO also invalidate device list caches cli.groupCacheLock.Lock() From c6f8e7fe42656274e3b8d869ca7bc12847e32d8f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 16:42:07 +0300 Subject: [PATCH 088/163] Add phone number consent message builders --- phone_number_message.go | 18 ++++++++++++++++++ phone_number_message_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 phone_number_message.go create mode 100644 phone_number_message_test.go diff --git a/phone_number_message.go b/phone_number_message.go new file mode 100644 index 000000000..ec4c93102 --- /dev/null +++ b/phone_number_message.go @@ -0,0 +1,18 @@ +package whatsmeow + +import "go.mau.fi/whatsmeow/proto/waE2E" + +func BuildRequestPhoneNumberMessage(contextInfo *waE2E.ContextInfo) *waE2E.Message { + return &waE2E.Message{ + RequestPhoneNumberMessage: &waE2E.RequestPhoneNumberMessage{ + ContextInfo: contextInfo, + }, + } +} + +func BuildSharePhoneNumberMessage() *waE2E.Message { + messageType := waE2E.ProtocolMessage_SHARE_PHONE_NUMBER + return &waE2E.Message{ + ProtocolMessage: &waE2E.ProtocolMessage{Type: &messageType}, + } +} diff --git a/phone_number_message_test.go b/phone_number_message_test.go new file mode 100644 index 000000000..d7e1154f6 --- /dev/null +++ b/phone_number_message_test.go @@ -0,0 +1,35 @@ +package whatsmeow + +import ( + "testing" + + "go.mau.fi/whatsmeow/proto/waE2E" +) + +func TestBuildRequestPhoneNumberMessage(t *testing.T) { + contextInfo := &waE2E.ContextInfo{StanzaID: stringPtr("request-id")} + message := BuildRequestPhoneNumberMessage(contextInfo) + + request := message.GetRequestPhoneNumberMessage() + if request == nil { + t.Fatal("expected request phone number message") + } + if request.GetContextInfo().GetStanzaID() != "request-id" { + t.Fatalf("unexpected context info: %+v", request.GetContextInfo()) + } +} + +func TestBuildSharePhoneNumberMessage(t *testing.T) { + message := BuildSharePhoneNumberMessage() + protocolMessage := message.GetProtocolMessage() + if protocolMessage == nil { + t.Fatal("expected protocol message") + } + if protocolMessage.GetType() != waE2E.ProtocolMessage_SHARE_PHONE_NUMBER { + t.Fatalf("unexpected protocol message type: %s", protocolMessage.GetType()) + } +} + +func stringPtr(value string) *string { + return &value +} From 3e533204ad970eb7098898258c90c0315d3a827c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 17:12:01 +0300 Subject: [PATCH 089/163] Use LIDs as Signal identities and support usernames --- appstate.go | 4 + lid_resolution_test.go | 45 +++++++ message.go | 63 +++++++++- send.go | 15 +-- store/contact_test.go | 20 ++++ store/sqlstore/store.go | 42 +++++-- store/sqlstore/upgrades/00-latest-schema.sql | 1 + .../sqlstore/upgrades/17-contact-username.sql | 2 + store/store.go | 9 +- types/user.go | 8 ++ user.go | 111 ++++++++++++++++++ username_contact_test.go | 27 +++++ username_resolution_test.go | 59 ++++++++++ 13 files changed, 379 insertions(+), 27 deletions(-) create mode 100644 lid_resolution_test.go create mode 100644 store/contact_test.go create mode 100644 store/sqlstore/upgrades/17-contact-username.sql create mode 100644 username_contact_test.go create mode 100644 username_resolution_test.go diff --git a/appstate.go b/appstate.go index 1cf1b4786..3a1df05c1 100644 --- a/appstate.go +++ b/appstate.go @@ -230,6 +230,7 @@ func (cli *Client) filterContacts(mutations []appstate.Mutation) ([]appstate.Mut JID: jid, FirstName: act.GetFirstName(), FullName: act.GetFullName(), + Username: act.GetUsername(), }) } else { filteredMutations = append(filteredMutations, mutation) @@ -313,6 +314,9 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa eventToDispatch = &events.Contact{JID: jid, Timestamp: ts, Action: act, FromFullSync: fullSync} if cli.Store.Contacts != nil { storeUpdateError = cli.Store.Contacts.PutContactName(ctx, jid, act.GetFirstName(), act.GetFullName()) + if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && storeUpdateError == nil { + storeUpdateError = usernameStore.PutContactUsername(ctx, jid, act.GetUsername()) + } } case appstate.IndexClearChat: act := mutation.Action.GetClearChatAction() diff --git a/lid_resolution_test.go b/lid_resolution_test.go new file mode 100644 index 000000000..182e934fb --- /dev/null +++ b/lid_resolution_test.go @@ -0,0 +1,45 @@ +package whatsmeow + +import ( + "context" + "testing" + + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" + waLog "go.mau.fi/whatsmeow/util/log" +) + +type cachedLIDStore struct { + store.NoopStore + pn types.JID + lid types.JID +} + +func (cached *cachedLIDStore) GetLIDForPN(_ context.Context, pn types.JID) (types.JID, error) { + if pn.ToNonAD() == cached.pn { + return cached.lid, nil + } + return types.EmptyJID, nil +} + +func TestResolveLIDUsesCachedMapping(t *testing.T) { + pn := types.NewJID("15550001111", types.DefaultUserServer) + lid := types.NewJID("100000011111111", types.HiddenUserServer) + lids := &cachedLIDStore{pn: pn, lid: lid} + client := NewClient(&store.Device{LIDs: lids}, waLog.Noop) + + resolved, err := client.resolveLID(context.Background(), pn) + if err != nil { + t.Fatal(err) + } + if resolved != lid { + t.Fatalf("resolved LID = %s, want %s", resolved, lid) + } +} + +func TestResolveLIDRejectsNonPhoneJID(t *testing.T) { + client := NewClient(&store.Device{LIDs: &store.NoopStore{}}, waLog.Noop) + if _, err := client.resolveLID(context.Background(), types.NewJID("100000011111111", types.HiddenUserServer)); err == nil { + t.Fatal("expected non-PN JID to fail") + } +} diff --git a/message.go b/message.go index 2f103ba19..0eaab1301 100644 --- a/message.go +++ b/message.go @@ -343,14 +343,21 @@ func (cli *Client) decryptMessages(ctx context.Context, info *types.MessageInfo, if info.SenderAlt.Server == types.HiddenUserServer { senderEncryptionJID = info.SenderAlt cli.migrateSessionStore(ctx, info.Sender, info.SenderAlt) - } else if lid, err := cli.Store.LIDs.GetLIDForPN(ctx, info.Sender); err != nil { - cli.Log.Errorf("Failed to get LID for %s: %v", info.Sender, err) - } else if !lid.IsEmpty() { + } else if lid, err := cli.resolveLID(ctx, info.Sender); err != nil { + cli.Log.Errorf("Failed to resolve LID for %s: %v", info.Sender, err) + if cli.SynchronousAck { + cli.sendRetryReceipt(ctx, node, info, false) + cli.sendAck(ctx, node, 0) + } else { + go cli.sendRetryReceipt(context.WithoutCancel(ctx), node, info, false) + go cli.sendAck(ctx, node, 0) + } + cli.dispatchEvent(&events.UndecryptableMessage{Info: *info}) + return + } else { cli.migrateSessionStore(ctx, info.Sender, lid) senderEncryptionJID = lid info.SenderAlt = lid - } else { - cli.Log.Warnf("No LID found for %s", info.Sender) } } var recognizedStanza, protobufFailed bool @@ -812,6 +819,9 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History if len(historySync.GetPhoneNumberToLidMappings()) > 0 { cli.storeHistoricalPNLIDMappings(ctx, historySync.GetPhoneNumberToLidMappings()) } + if len(historySync.GetInlineContacts()) > 0 { + cli.storeHistoricalInlineContacts(ctx, historySync.GetInlineContacts()) + } if historySync.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME { cli.handleHistoricalPushNames(ctx, historySync.GetPushnames()) } else if len(historySync.GetConversations()) > 0 { @@ -831,6 +841,49 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History return &historySync, nil } +func historicalInlineContactEntries(contacts []*waHistorySync.InlineContact) ([]store.ContactEntry, []store.LIDMapping) { + entries := make([]store.ContactEntry, 0, len(contacts)) + mappings := make([]store.LIDMapping, 0, len(contacts)) + for _, contact := range contacts { + if contact == nil { + continue + } + pn, _ := types.ParseJID(contact.GetPnJID()) + lid, _ := types.ParseJID(contact.GetLidJID()) + if pn.Server == types.DefaultUserServer && lid.Server == types.HiddenUserServer { + mappings = append(mappings, store.LIDMapping{PN: pn.ToNonAD(), LID: lid.ToNonAD()}) + } + jid := lid.ToNonAD() + if jid.Server != types.HiddenUserServer { + jid = pn.ToNonAD() + } + if jid.Server != types.HiddenUserServer && jid.Server != types.DefaultUserServer { + continue + } + entries = append(entries, store.ContactEntry{ + JID: jid, + FirstName: contact.GetFirstName(), + FullName: contact.GetFullName(), + Username: contact.GetUsername(), + }) + } + return entries, mappings +} + +func (cli *Client) storeHistoricalInlineContacts(ctx context.Context, contacts []*waHistorySync.InlineContact) { + entries, mappings := historicalInlineContactEntries(contacts) + if len(mappings) > 0 { + if err := cli.Store.LIDs.PutManyLIDMappings(ctx, mappings); err != nil { + cli.Log.Warnf("Failed to store LID mappings from inline contacts: %v", err) + } + } + if len(entries) > 0 && cli.Store.Contacts != nil { + if err := cli.Store.Contacts.PutAllContactNames(ctx, entries); err != nil { + cli.Log.Warnf("Failed to store inline contacts: %v", err) + } + } +} + func (cli *Client) handleAppStateSyncKeyShare(ctx context.Context, keys *waE2E.AppStateSyncKeyShare) { onlyResyncIfNotSynced := true diff --git a/send.go b/send.go index 0ba17ac0e..dfe052d73 100644 --- a/send.go +++ b/send.go @@ -337,21 +337,10 @@ func (cli *Client) SendMessage(ctx context.Context, to types.JID, message *waE2E } else if to.Server == types.DefaultUserServer && !req.Peer { start := time.Now() var toLID types.JID - toLID, err = cli.Store.LIDs.GetLIDForPN(ctx, to) + toLID, err = cli.resolveLID(ctx, to) if err != nil { - err = fmt.Errorf("failed to get LID for PN %s: %w", to, err) + err = fmt.Errorf("failed to resolve LID for PN %s: %w", to, err) return - } else if toLID.IsEmpty() { - var info map[types.JID]types.UserInfo - cli.Log.Debugf("LID for %s not found, fetching user info", to) - info, err = cli.GetUserInfo(ctx, []types.JID{to}) - if err != nil { - err = fmt.Errorf("failed to get user info for %s to fill LID cache: %w", to, err) - return - } else if toLID = info[to].LID; toLID.IsEmpty() { - err = fmt.Errorf("no LID found for %s from server", to) - return - } } resp.DebugTimings.LIDFetch = time.Since(start) cli.Log.Debugf("Replacing SendMessage destination with LID %s -> %s", to, toLID) diff --git a/store/contact_test.go b/store/contact_test.go new file mode 100644 index 000000000..dad27f458 --- /dev/null +++ b/store/contact_test.go @@ -0,0 +1,20 @@ +package store + +import ( + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestContactEntryMassInsertIncludesUsername(t *testing.T) { + entry := ContactEntry{ + JID: types.NewJID("100000011111111", types.HiddenUserServer), + FirstName: "Example", + FullName: "Example User", + Username: "example", + } + values := entry.GetMassInsertValues() + if values[3] != "example" { + t.Fatalf("username value = %#v", values[3]) + } +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index c164e0ba6..c54a93840 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -855,6 +855,14 @@ const ( INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name) VALUES ($1, $2, $3, $4) ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name ` + putContactNamesQuery = ` + INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name, username) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name, username=excluded.username + ` + putContactUsernameQuery = ` + INSERT INTO whatsmeow_contacts (our_jid, their_jid, username) VALUES ($1, $2, $3) + ON CONFLICT (our_jid, their_jid) DO UPDATE SET username=excluded.username + ` putRedactedPhoneQuery = ` INSERT INTO whatsmeow_contacts (our_jid, their_jid, redacted_phone) VALUES ($1, $2, $3) @@ -869,15 +877,15 @@ const ( ON CONFLICT (our_jid, their_jid) DO UPDATE SET business_name=excluded.business_name ` getContactQuery = ` - SELECT first_name, full_name, push_name, business_name, redacted_phone FROM whatsmeow_contacts WHERE our_jid=$1 AND their_jid=$2 + SELECT first_name, full_name, push_name, business_name, redacted_phone, username FROM whatsmeow_contacts WHERE our_jid=$1 AND their_jid=$2 ` getAllContactsQuery = ` - SELECT their_jid, first_name, full_name, push_name, business_name, redacted_phone FROM whatsmeow_contacts WHERE our_jid=$1 + SELECT their_jid, first_name, full_name, push_name, business_name, redacted_phone, username FROM whatsmeow_contacts WHERE our_jid=$1 ` ) var putContactNamesMassInsertBuilder = dbutil.NewMassInsertBuilder[store.ContactEntry, [1]any]( - putContactNameQuery, "($1, $%d, $%d, $%d)", + putContactNamesQuery, "($1, $%d, $%d, $%d, $%d)", ) var putRedactedPhonesMassInsertBuilder = dbutil.NewMassInsertBuilder[store.RedactedPhoneEntry, [1]any]( @@ -946,6 +954,24 @@ func (s *SQLStore) PutContactName(ctx context.Context, user types.JID, firstName return nil } +func (s *SQLStore) PutContactUsername(ctx context.Context, user types.JID, username string) error { + s.contactCacheLock.Lock() + defer s.contactCacheLock.Unlock() + + cached, err := s.getContact(ctx, user) + if err != nil { + return err + } + if cached.Username != username { + if _, err = s.db.Exec(ctx, putContactUsernameQuery, s.JID, user, username); err != nil { + return err + } + cached.Username = username + cached.Found = true + } + return nil +} + const contactBatchSize = 300 func (s *SQLStore) PutAllContactNames(ctx context.Context, contacts []store.ContactEntry) error { @@ -1020,8 +1046,8 @@ func (s *SQLStore) getContact(ctx context.Context, user types.JID) (*types.Conta return cached, nil } - var first, full, push, business, redactedPhone sql.NullString - err := s.db.QueryRow(ctx, getContactQuery, s.JID, user).Scan(&first, &full, &push, &business, &redactedPhone) + var first, full, push, business, redactedPhone, username sql.NullString + err := s.db.QueryRow(ctx, getContactQuery, s.JID, user).Scan(&first, &full, &push, &business, &redactedPhone, &username) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } @@ -1032,6 +1058,7 @@ func (s *SQLStore) getContact(ctx context.Context, user types.JID) (*types.Conta PushName: push.String, BusinessName: business.String, RedactedPhone: redactedPhone.String, + Username: username.String, } s.setCachedContactLocked(user, info) return info, nil @@ -1054,8 +1081,8 @@ type contactTuple struct { var convertContactRow = dbutil.ConvertRowFn[*contactTuple](func(rows dbutil.Scannable) (*contactTuple, error) { var jid types.JID - var first, full, push, business, redactedPhone sql.NullString - err := rows.Scan(&jid, &first, &full, &push, &business, &redactedPhone) + var first, full, push, business, redactedPhone, username sql.NullString + err := rows.Scan(&jid, &first, &full, &push, &business, &redactedPhone, &username) if err != nil { return nil, fmt.Errorf("error scanning row: %w", err) } @@ -1068,6 +1095,7 @@ var convertContactRow = dbutil.ConvertRowFn[*contactTuple](func(rows dbutil.Scan PushName: push.String, BusinessName: business.String, RedactedPhone: redactedPhone.String, + Username: username.String, }, }, nil }) diff --git a/store/sqlstore/upgrades/00-latest-schema.sql b/store/sqlstore/upgrades/00-latest-schema.sql index a4820e076..4cd7398d5 100644 --- a/store/sqlstore/upgrades/00-latest-schema.sql +++ b/store/sqlstore/upgrades/00-latest-schema.sql @@ -114,6 +114,7 @@ CREATE TABLE whatsmeow_contacts ( push_name TEXT, business_name TEXT, redacted_phone TEXT, + username TEXT, PRIMARY KEY (our_jid, their_jid), FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE diff --git a/store/sqlstore/upgrades/17-contact-username.sql b/store/sqlstore/upgrades/17-contact-username.sql new file mode 100644 index 000000000..ece74f0c1 --- /dev/null +++ b/store/sqlstore/upgrades/17-contact-username.sql @@ -0,0 +1,2 @@ +-- v17: Persist the optional WhatsApp username alongside the LID-keyed contact. +ALTER TABLE whatsmeow_contacts ADD COLUMN username TEXT; diff --git a/store/store.go b/store/store.go index 8b881de95..62ad176af 100644 --- a/store/store.go +++ b/store/store.go @@ -85,10 +85,11 @@ type ContactEntry struct { JID types.JID FirstName string FullName string + Username string } -func (ce ContactEntry) GetMassInsertValues() [3]any { - return [...]any{ce.JID.String(), ce.FirstName, ce.FullName} +func (ce ContactEntry) GetMassInsertValues() [4]any { + return [...]any{ce.JID.String(), ce.FirstName, ce.FullName, ce.Username} } type RedactedPhoneEntry struct { @@ -110,6 +111,10 @@ type ContactStore interface { GetAllContacts(ctx context.Context) (map[types.JID]types.ContactInfo, error) } +type ContactUsernameStore interface { + PutContactUsername(ctx context.Context, user types.JID, username string) error +} + var MutedForever = time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC) type ChatSettingsStore interface { diff --git a/types/user.go b/types/user.go index f79d117d2..a25243b08 100644 --- a/types/user.go +++ b/types/user.go @@ -33,6 +33,7 @@ type UserInfo struct { PictureID string Devices []JID LID JID + Username string } type BotListInfo struct { @@ -77,6 +78,7 @@ type ContactInfo struct { FullName string PushName string BusinessName string + Username string // Only for LID members encountered in groups, the phone number in the form "+1∙∙∙∙∙∙∙∙80" RedactedPhone string } @@ -101,6 +103,12 @@ type IsOnWhatsAppResponse struct { VerifiedName *VerifiedName // If the phone is a business, the verified business details. } +type UsernameResolution struct { + LID JID + Username string + KeyRequired bool +} + // BusinessMessageLinkTarget contains the info that is found using a business message link (see Client.ResolveBusinessMessageLink) type BusinessMessageLinkTarget struct { JID JID // The JID of the business. diff --git a/user.go b/user.go index f307c5521..ba8eb4032 100644 --- a/user.go +++ b/user.go @@ -236,6 +236,7 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types {Tag: "picture"}, {Tag: "devices", Attrs: waBinary.Attrs{"version": "2"}}, {Tag: "lid"}, + {Tag: "username"}, }, UsyncQueryExtras{ IncludePrivacyToken: true, }) @@ -261,6 +262,8 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types lidTag := child.GetChildByTag("lid") info.LID = lidTag.AttrGetter().OptionalJIDOrEmpty("val") + username, _ := child.GetChildByTag("username").Content.([]byte) + info.Username = string(username) if !info.LID.IsEmpty() { mappings = append(mappings, store.LIDMapping{PN: jid, LID: info.LID}) @@ -270,6 +273,15 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types cli.updateBusinessName(ctx, jid, info.LID, nil, verifiedName.Details.GetVerifiedName()) } respData[jid] = info + if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && info.Username != "" { + usernameJID := info.LID + if usernameJID.IsEmpty() { + usernameJID = jid + } + if err := usernameStore.PutContactUsername(ctx, usernameJID, info.Username); err != nil { + cli.Log.Warnf("Failed to store username for %s: %v", usernameJID, err) + } + } } err = cli.Store.LIDs.PutManyLIDMappings(ctx, mappings) @@ -281,6 +293,95 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types return respData, nil } +var ErrUsernameNotFound = errors.New("username not found") + +func parseUsernameResolution(list *waBinary.Node) (types.UsernameResolution, error) { + children := list.GetChildren() + if len(children) != 1 || children[0].Tag != "user" { + return types.UsernameResolution{}, ErrUsernameNotFound + } + user := children[0] + contact := user.GetChildByTag("contact") + contactAttrs := contact.AttrGetter() + if contactAttrs.OptionalString("type") == "out" { + return types.UsernameResolution{}, ErrUsernameNotFound + } + lid := user.AttrGetter().OptionalJIDOrEmpty("jid").ToNonAD() + if lid.IsEmpty() { + return types.UsernameResolution{KeyRequired: true}, nil + } + if lid.Server != types.HiddenUserServer { + return types.UsernameResolution{}, fmt.Errorf("username resolved to non-LID JID %s", lid) + } + return types.UsernameResolution{ + LID: lid, + Username: contactAttrs.OptionalString("username"), + }, nil +} + +func (cli *Client) ResolveUsername(ctx context.Context, username, key string) (types.UsernameResolution, error) { + username = strings.TrimPrefix(strings.TrimSpace(username), "@") + if len(username) < 3 || len(username) > 35 { + return types.UsernameResolution{}, fmt.Errorf("username must contain 3 to 35 characters") + } + if key != "" { + if len(key) != 4 { + return types.UsernameResolution{}, fmt.Errorf("username key must contain 4 digits") + } + for _, char := range key { + if char < '0' || char > '9' { + return types.UsernameResolution{}, fmt.Errorf("username key must contain 4 digits") + } + } + } + list, err := cli.usync(ctx, []types.JID{types.EmptyJID}, "query", "interactive", []waBinary.Node{ + {Tag: "contact", Attrs: waBinary.Attrs{"addressing_mode": "lid"}}, + {Tag: "business", Content: []waBinary.Node{{Tag: "verified_name"}}}, + }, UsyncQueryExtras{Username: username, UsernameKey: key}) + if err != nil { + return types.UsernameResolution{}, err + } + result, err := parseUsernameResolution(list) + if err != nil || result.KeyRequired { + return result, err + } + if result.Username == "" { + result.Username = username + } + if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok { + if err = usernameStore.PutContactUsername(ctx, result.LID, result.Username); err != nil { + cli.Log.Warnf("Failed to store username for %s: %v", result.LID, err) + } + } + return result, nil +} + +func (cli *Client) resolveLID(ctx context.Context, phone types.JID) (types.JID, error) { + phone = phone.ToNonAD() + if phone.Server != types.DefaultUserServer { + return types.EmptyJID, fmt.Errorf("cannot resolve non-PN JID %s to LID", phone) + } + if cli.Store == nil || cli.Store.LIDs == nil { + return types.EmptyJID, fmt.Errorf("LID store is unavailable") + } + lid, err := cli.Store.LIDs.GetLIDForPN(ctx, phone) + if err != nil { + return types.EmptyJID, fmt.Errorf("get cached LID for %s: %w", phone, err) + } + if !lid.IsEmpty() { + return lid.ToNonAD(), nil + } + info, err := cli.GetUserInfo(ctx, []types.JID{phone}) + if err != nil { + return types.EmptyJID, fmt.Errorf("resolve LID for %s with USync: %w", phone, err) + } + lid = info[phone].LID.ToNonAD() + if lid.IsEmpty() { + return types.EmptyJID, fmt.Errorf("USync returned no LID for %s", phone) + } + return lid, nil +} + func (cli *Client) GetBotListV2(ctx context.Context) ([]types.BotListInfo, error) { resp, err := cli.sendIQ(ctx, infoQuery{ To: types.ServerJID, @@ -882,6 +983,8 @@ func (cli *Client) getFBIDDevices(ctx context.Context, jids []types.JID) ([]type type UsyncQueryExtras struct { BotListInfo []types.BotListInfo IncludePrivacyToken bool + Username string + UsernameKey string } func (cli *Client) usync(ctx context.Context, jids []types.JID, mode, context string, query []waBinary.Node, extra ...UsyncQueryExtras) (*waBinary.Node, error) { @@ -899,6 +1002,14 @@ func (cli *Client) usync(ctx context.Context, jids []types.JID, mode, context st for i, jid := range jids { userList[i].Tag = "user" jid = jid.ToNonAD() + if extras.Username != "" { + attrs := waBinary.Attrs{"username": extras.Username} + if extras.UsernameKey != "" { + attrs["pin"] = extras.UsernameKey + } + userList[i].Content = []waBinary.Node{{Tag: "contact", Attrs: attrs}} + continue + } switch jid.Server { case types.LegacyUserServer: diff --git a/username_contact_test.go b/username_contact_test.go new file mode 100644 index 000000000..0b2814580 --- /dev/null +++ b/username_contact_test.go @@ -0,0 +1,27 @@ +package whatsmeow + +import ( + "testing" + + "google.golang.org/protobuf/proto" + + "go.mau.fi/whatsmeow/appstate" + "go.mau.fi/whatsmeow/proto/waSyncAction" +) + +func TestFilterContactsPreservesUsername(t *testing.T) { + client := &Client{} + _, contacts := client.filterContacts([]appstate.Mutation{{ + Index: []string{"contact", "100000011111111@lid"}, + Action: &waSyncAction.SyncActionValue{ContactAction: &waSyncAction.ContactAction{ + FullName: proto.String("Example User"), + Username: proto.String("example"), + }}, + }}) + if len(contacts) != 1 { + t.Fatalf("got %d contacts", len(contacts)) + } + if contacts[0].Username != "example" { + t.Fatalf("username = %q", contacts[0].Username) + } +} diff --git a/username_resolution_test.go b/username_resolution_test.go new file mode 100644 index 000000000..fe0a880cb --- /dev/null +++ b/username_resolution_test.go @@ -0,0 +1,59 @@ +package whatsmeow + +import ( + "testing" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/proto/waHistorySync" + "go.mau.fi/whatsmeow/types" +) + +func TestParseUsernameResolution(t *testing.T) { + list := &waBinary.Node{Tag: "list", Content: []waBinary.Node{{ + Tag: "user", + Attrs: waBinary.Attrs{"jid": types.NewJID("100000011111111", types.HiddenUserServer)}, + Content: []waBinary.Node{{ + Tag: "contact", + Attrs: waBinary.Attrs{"type": "in", "username": "example"}, + }}, + }}} + result, err := parseUsernameResolution(list) + if err != nil { + t.Fatal(err) + } + if result.LID.String() != "100000011111111@lid" || result.Username != "example" || result.KeyRequired { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestParseUsernameResolutionDetectsRequiredKey(t *testing.T) { + list := &waBinary.Node{Tag: "list", Content: []waBinary.Node{{ + Tag: "user", + Content: []waBinary.Node{{ + Tag: "contact", + Attrs: waBinary.Attrs{"type": "in"}, + }}, + }}} + result, err := parseUsernameResolution(list) + if err != nil { + t.Fatal(err) + } + if !result.KeyRequired { + t.Fatal("expected username key requirement") + } +} + +func TestHistoricalInlineContactsPreferLID(t *testing.T) { + entries, mappings := historicalInlineContactEntries([]*waHistorySync.InlineContact{{ + PnJID: stringPtr("15550001111@s.whatsapp.net"), + LidJID: stringPtr("100000011111111@lid"), + FullName: stringPtr("Example User"), + Username: stringPtr("example"), + }}) + if len(entries) != 1 || entries[0].JID.String() != "100000011111111@lid" || entries[0].Username != "example" { + t.Fatalf("unexpected entries: %+v", entries) + } + if len(mappings) != 1 || mappings[0].LID != entries[0].JID { + t.Fatalf("unexpected mappings: %+v", mappings) + } +} From 083bcc3c6027efcaf549ba1f74db8f99a771b7c6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:10:06 +0300 Subject: [PATCH 090/163] fix: preserve device when resolving LIDs --- lid_resolution_test.go | 10 ++++++---- user.go | 6 +++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lid_resolution_test.go b/lid_resolution_test.go index 182e934fb..8505049c3 100644 --- a/lid_resolution_test.go +++ b/lid_resolution_test.go @@ -23,17 +23,19 @@ func (cached *cachedLIDStore) GetLIDForPN(_ context.Context, pn types.JID) (type } func TestResolveLIDUsesCachedMapping(t *testing.T) { - pn := types.NewJID("15550001111", types.DefaultUserServer) + pn := types.NewADJID("15550001111", types.WhatsAppDomain, 7) lid := types.NewJID("100000011111111", types.HiddenUserServer) - lids := &cachedLIDStore{pn: pn, lid: lid} + lids := &cachedLIDStore{pn: pn.ToNonAD(), lid: lid} client := NewClient(&store.Device{LIDs: lids}, waLog.Noop) resolved, err := client.resolveLID(context.Background(), pn) if err != nil { t.Fatal(err) } - if resolved != lid { - t.Fatalf("resolved LID = %s, want %s", resolved, lid) + want := lid + want.Device = pn.Device + if resolved != want { + t.Fatalf("resolved LID = %s, want %s", resolved, want) } } diff --git a/user.go b/user.go index ba8eb4032..b1b4084fb 100644 --- a/user.go +++ b/user.go @@ -357,6 +357,7 @@ func (cli *Client) ResolveUsername(ctx context.Context, username, key string) (t } func (cli *Client) resolveLID(ctx context.Context, phone types.JID) (types.JID, error) { + device := phone.Device phone = phone.ToNonAD() if phone.Server != types.DefaultUserServer { return types.EmptyJID, fmt.Errorf("cannot resolve non-PN JID %s to LID", phone) @@ -369,7 +370,9 @@ func (cli *Client) resolveLID(ctx context.Context, phone types.JID) (types.JID, return types.EmptyJID, fmt.Errorf("get cached LID for %s: %w", phone, err) } if !lid.IsEmpty() { - return lid.ToNonAD(), nil + lid = lid.ToNonAD() + lid.Device = device + return lid, nil } info, err := cli.GetUserInfo(ctx, []types.JID{phone}) if err != nil { @@ -379,6 +382,7 @@ func (cli *Client) resolveLID(ctx context.Context, phone types.JID) (types.JID, if lid.IsEmpty() { return types.EmptyJID, fmt.Errorf("USync returned no LID for %s", phone) } + lid.Device = device return lid, nil } From ff0ceba98a1705003dcb3661d3bcb940713bb888 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:32:15 +0300 Subject: [PATCH 091/163] fix: persist LID contact mutations --- appstate.go | 19 +++++++++++- username_contact_test.go | 66 ++++++++++++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/appstate.go b/appstate.go index 3a1df05c1..f775f1756 100644 --- a/appstate.go +++ b/appstate.go @@ -223,7 +223,7 @@ func (cli *Client) filterContacts(mutations []appstate.Mutation) ([]appstate.Mut filteredMutations := mutations[:0] contacts := make([]store.ContactEntry, 0, len(mutations)) for _, mutation := range mutations { - if mutation.Index[0] == "contact" && len(mutation.Index) > 1 { + if len(mutation.Index) > 1 && mutation.Index[0] == appstate.IndexContact { jid, _ := types.ParseJID(mutation.Index[1]) act := mutation.Action.GetContactAction() contacts = append(contacts, store.ContactEntry{ @@ -232,6 +232,15 @@ func (cli *Client) filterContacts(mutations []appstate.Mutation) ([]appstate.Mut FullName: act.GetFullName(), Username: act.GetUsername(), }) + } else if len(mutation.Index) > 1 && mutation.Index[0] == appstate.IndexLIDContact { + jid, _ := types.ParseJID(mutation.Index[1]) + act := mutation.Action.GetLidContactAction() + contacts = append(contacts, store.ContactEntry{ + JID: jid, + FirstName: act.GetFirstName(), + FullName: act.GetFullName(), + Username: act.GetUsername(), + }) } else { filteredMutations = append(filteredMutations, mutation) } @@ -318,6 +327,14 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa storeUpdateError = usernameStore.PutContactUsername(ctx, jid, act.GetUsername()) } } + case appstate.IndexLIDContact: + act := mutation.Action.GetLidContactAction() + if cli.Store.Contacts != nil { + storeUpdateError = cli.Store.Contacts.PutContactName(ctx, jid, act.GetFirstName(), act.GetFullName()) + if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && storeUpdateError == nil { + storeUpdateError = usernameStore.PutContactUsername(ctx, jid, act.GetUsername()) + } + } case appstate.IndexClearChat: act := mutation.Action.GetClearChatAction() var deleteMedia bool diff --git a/username_contact_test.go b/username_contact_test.go index 0b2814580..7c7944d3f 100644 --- a/username_contact_test.go +++ b/username_contact_test.go @@ -1,27 +1,73 @@ package whatsmeow import ( + "context" "testing" "google.golang.org/protobuf/proto" "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/proto/waSyncAction" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" ) func TestFilterContactsPreservesUsername(t *testing.T) { client := &Client{} - _, contacts := client.filterContacts([]appstate.Mutation{{ - Index: []string{"contact", "100000011111111@lid"}, - Action: &waSyncAction.SyncActionValue{ContactAction: &waSyncAction.ContactAction{ - FullName: proto.String("Example User"), - Username: proto.String("example"), - }}, - }}) - if len(contacts) != 1 { + _, contacts := client.filterContacts([]appstate.Mutation{ + { + Index: []string{appstate.IndexContact, "100000011111111@lid"}, + Action: &waSyncAction.SyncActionValue{ContactAction: &waSyncAction.ContactAction{ + FullName: proto.String("Example User"), + Username: proto.String("example"), + }}, + }, + { + Index: []string{appstate.IndexLIDContact, "100000022222222@lid"}, + Action: &waSyncAction.SyncActionValue{LidContactAction: &waSyncAction.LidContactAction{ + FullName: proto.String("LID User"), + Username: proto.String("lid-example"), + }}, + }, + }) + if len(contacts) != 2 { t.Fatalf("got %d contacts", len(contacts)) } - if contacts[0].Username != "example" { - t.Fatalf("username = %q", contacts[0].Username) + if contacts[0].Username != "example" || contacts[1].Username != "lid-example" { + t.Fatalf("usernames = %q, %q", contacts[0].Username, contacts[1].Username) + } +} + +type recordingLIDContactStore struct { + store.NoopStore + jid types.JID + fullName string + username string +} + +func (contacts *recordingLIDContactStore) PutContactName(_ context.Context, jid types.JID, _, fullName string) error { + contacts.jid = jid + contacts.fullName = fullName + return nil +} + +func (contacts *recordingLIDContactStore) PutContactUsername(_ context.Context, jid types.JID, username string) error { + contacts.jid = jid + contacts.username = username + return nil +} + +func TestDispatchLIDContactPersistsNamesAndUsername(t *testing.T) { + contacts := &recordingLIDContactStore{} + client := &Client{Store: &store.Device{Contacts: contacts}} + lid := types.NewJID("100000011111111", types.HiddenUserServer) + client.dispatchAppState(context.Background(), appstate.WAPatchCriticalUnblockLow, appstate.Mutation{ + Index: []string{appstate.IndexLIDContact, lid.String()}, + Action: &waSyncAction.SyncActionValue{LidContactAction: &waSyncAction.LidContactAction{ + FullName: proto.String("LID User"), Username: proto.String("lid-example"), + }}, + }, false) + if contacts.jid != lid || contacts.fullName != "LID User" || contacts.username != "lid-example" { + t.Fatalf("unexpected persisted contact: %#v", contacts) } } From d2dfea8cefa017bb5741bcb4bc90cffb2da0fb4e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 11:49:40 +0300 Subject: [PATCH 092/163] fix: advance username schema migration --- store/sqlstore/upgrades/00-latest-schema.sql | 2 +- ...t-username.sql => 18-contact-username.sql} | 2 +- store/sqlstore/upgrades/upgrades_test.go | 166 ++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) rename store/sqlstore/upgrades/{17-contact-username.sql => 18-contact-username.sql} (53%) create mode 100644 store/sqlstore/upgrades/upgrades_test.go diff --git a/store/sqlstore/upgrades/00-latest-schema.sql b/store/sqlstore/upgrades/00-latest-schema.sql index 4cd7398d5..0d9e4c51e 100644 --- a/store/sqlstore/upgrades/00-latest-schema.sql +++ b/store/sqlstore/upgrades/00-latest-schema.sql @@ -1,4 +1,4 @@ --- v0 -> v17 (compatible with v8+): Latest schema +-- v0 -> v18 (compatible with v8+): Latest schema CREATE TABLE whatsmeow_device ( jid TEXT PRIMARY KEY, lid TEXT, diff --git a/store/sqlstore/upgrades/17-contact-username.sql b/store/sqlstore/upgrades/18-contact-username.sql similarity index 53% rename from store/sqlstore/upgrades/17-contact-username.sql rename to store/sqlstore/upgrades/18-contact-username.sql index ece74f0c1..828973d37 100644 --- a/store/sqlstore/upgrades/17-contact-username.sql +++ b/store/sqlstore/upgrades/18-contact-username.sql @@ -1,2 +1,2 @@ --- v17: Persist the optional WhatsApp username alongside the LID-keyed contact. +-- v18: Persist the optional WhatsApp username alongside the LID-keyed contact. ALTER TABLE whatsmeow_contacts ADD COLUMN username TEXT; diff --git a/store/sqlstore/upgrades/upgrades_test.go b/store/sqlstore/upgrades/upgrades_test.go new file mode 100644 index 000000000..6f2b39cd5 --- /dev/null +++ b/store/sqlstore/upgrades/upgrades_test.go @@ -0,0 +1,166 @@ +package upgrades + +import ( + "bufio" + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "io/fs" + "regexp" + "strconv" + "strings" + "testing" + + "go.mau.fi/util/dbutil" +) + +var upgradeVersionPattern = regexp.MustCompile(`^-- (?:v(\d+) -> )?v(\d+)`) + +func TestUpgradeSourcesHaveUniqueStartingVersions(t *testing.T) { + entries, err := fs.ReadDir(upgrades, ".") + if err != nil { + t.Fatal(err) + } + versions := make(map[int]string) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + file, err := upgrades.Open(entry.Name()) + if err != nil { + t.Fatal(err) + } + scanner := bufio.NewScanner(file) + if !scanner.Scan() { + _ = file.Close() + t.Fatalf("%s has no upgrade header", entry.Name()) + } + _ = file.Close() + matches := upgradeVersionPattern.FindStringSubmatch(scanner.Text()) + if matches == nil { + t.Fatalf("%s has an invalid upgrade header", entry.Name()) + } + to, err := strconv.Atoi(matches[2]) + if err != nil { + t.Fatal(err) + } + from := to - 1 + if matches[1] != "" { + from, err = strconv.Atoi(matches[1]) + if err != nil { + t.Fatal(err) + } + } + if previous, exists := versions[from]; exists { + t.Fatalf("%s and %s both register an upgrade starting at v%d", previous, entry.Name(), from) + } + versions[from] = entry.Name() + } +} + +type upgradeTestState struct { + executed []string + version int64 + compatVersion int64 +} + +type upgradeTestConnector struct{ state *upgradeTestState } + +func (c *upgradeTestConnector) Connect(context.Context) (driver.Conn, error) { + return &upgradeTestConn{state: c.state}, nil +} + +func (*upgradeTestConnector) Driver() driver.Driver { return upgradeTestDriver{} } + +type upgradeTestDriver struct{} + +func (upgradeTestDriver) Open(string) (driver.Conn, error) { + return nil, errors.New("use connector") +} + +type upgradeTestConn struct{ state *upgradeTestState } + +func (*upgradeTestConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} + +func (*upgradeTestConn) Close() error { return nil } + +func (c *upgradeTestConn) Begin() (driver.Tx, error) { return &upgradeTestTx{}, nil } + +func (c *upgradeTestConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { + return &upgradeTestTx{}, nil +} + +func (c *upgradeTestConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { + switch { + case strings.Contains(query, "information_schema.columns"): + return &upgradeTestRows{columns: []string{"exists"}, values: []driver.Value{true}}, nil + case strings.HasPrefix(query, "SELECT version, compat FROM whatsmeow_version"): + return &upgradeTestRows{columns: []string{"version", "compat"}, values: []driver.Value{c.state.version, c.state.compatVersion}}, nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } +} + +func (c *upgradeTestConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + c.state.executed = append(c.state.executed, query) + if strings.HasPrefix(query, "INSERT INTO whatsmeow_version") { + if len(args) != 2 { + return nil, fmt.Errorf("unexpected version argument count: %d", len(args)) + } + c.state.version = args[0].Value.(int64) + c.state.compatVersion = args[1].Value.(int64) + } + return driver.RowsAffected(1), nil +} + +type upgradeTestTx struct{} + +func (*upgradeTestTx) Commit() error { return nil } +func (*upgradeTestTx) Rollback() error { return nil } + +type upgradeTestRows struct { + columns []string + values []driver.Value + read bool +} + +func (r *upgradeTestRows) Columns() []string { return r.columns } +func (*upgradeTestRows) Close() error { return nil } +func (r *upgradeTestRows) Next(values []driver.Value) error { + if r.read { + return io.EOF + } + r.read = true + copy(values, r.values) + return nil +} + +func TestUpgradeFromCurrentDevSchemaAddsUsername(t *testing.T) { + state := &upgradeTestState{version: 17, compatVersion: 8} + rawDB := sql.OpenDB(&upgradeTestConnector{state: state}) + t.Cleanup(func() { _ = rawDB.Close() }) + db, err := dbutil.NewWithDB(rawDB, "postgres") + if err != nil { + t.Fatal(err) + } + db.VersionTable = "whatsmeow_version" + db.UpgradeTable = Table + if err = db.Upgrade(context.Background()); err != nil { + t.Fatal(err) + } + if state.version != 18 || state.compatVersion != 18 { + t.Fatalf("schema version = %d/%d, want 18/18", state.version, state.compatVersion) + } + want := "ALTER TABLE whatsmeow_contacts ADD COLUMN username TEXT;" + for _, query := range state.executed { + if strings.TrimSpace(query) == want { + return + } + } + t.Fatalf("username migration was not executed: %q", state.executed) +} From 77b6c48a2949ae2f542b62dbec297698cd071e7d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 11:52:12 +0300 Subject: [PATCH 093/163] fix: complete LID contact integration --- appstate.go | 1 + binary/xml.go | 2 +- binary/xml_test.go | 6 +++--- store/clientpayload.go | 1 + store/clientpayload_test.go | 9 +++++++++ store/sqlstore/store.go | 3 ++- store/sqlstore/store_test.go | 6 ++++++ types/events/appstate.go | 9 +++++++++ username_contact_test.go | 7 ++++++- 9 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 store/clientpayload_test.go diff --git a/appstate.go b/appstate.go index f775f1756..f964d3513 100644 --- a/appstate.go +++ b/appstate.go @@ -329,6 +329,7 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa } case appstate.IndexLIDContact: act := mutation.Action.GetLidContactAction() + eventToDispatch = &events.LIDContact{JID: jid, Timestamp: ts, Action: act, FromFullSync: fullSync} if cli.Store.Contacts != nil { storeUpdateError = cli.Store.Contacts.PutContactName(ctx, jid, act.GetFirstName(), act.GetFullName()) if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && storeUpdateError == nil { diff --git a/binary/xml.go b/binary/xml.go index e90152aa9..946bae222 100644 --- a/binary/xml.go +++ b/binary/xml.go @@ -22,7 +22,7 @@ var ( ) func sensitiveXMLAttribute(key string) bool { - return key == "auth" || key == "token" + return key == "auth" || key == "pin" || key == "token" } func sensitiveXMLNodeContent(tag string) bool { diff --git a/binary/xml_test.go b/binary/xml_test.go index a5f4f00ac..e8e63504c 100644 --- a/binary/xml_test.go +++ b/binary/xml_test.go @@ -38,12 +38,12 @@ func TestNodeStringRedactsSensitiveContent(t *testing.T) { func TestNodeStringRedactsSensitiveAttributes(t *testing.T) { logged := (Node{ Tag: "cover_photo", - Attrs: Attrs{"auth": "media-secret", "token": "upload-secret", "id": "cover-100"}, + Attrs: Attrs{"auth": "media-secret", "pin": "1234", "token": "upload-secret", "id": "cover-100"}, }).String() - if strings.Contains(logged, "media-secret") || strings.Contains(logged, "upload-secret") { + if strings.Contains(logged, "media-secret") || strings.Contains(logged, "1234") || strings.Contains(logged, "upload-secret") { t.Fatalf("sensitive attributes were not redacted: %s", logged) } - if !strings.Contains(logged, `id="cover-100"`) || strings.Count(logged, "[redacted]") != 2 { + if !strings.Contains(logged, `id="cover-100"`) || strings.Count(logged, "[redacted]") != 3 { t.Fatalf("unexpected redacted node: %s", logged) } } diff --git a/store/clientpayload.go b/store/clientpayload.go index 22da429e0..74181ce0a 100644 --- a/store/clientpayload.go +++ b/store/clientpayload.go @@ -151,6 +151,7 @@ var DeviceProps = &waCompanionReg.DeviceProps{ InitialSyncMaxMessagesPerChat: nil, SupportManusHistory: proto.Bool(true), SupportHatchHistory: proto.Bool(true), + SupportInlineContacts: proto.Bool(true), }, PlatformType: waCompanionReg.DeviceProps_UNKNOWN.Enum(), RequireFullSync: proto.Bool(false), diff --git a/store/clientpayload_test.go b/store/clientpayload_test.go new file mode 100644 index 000000000..3ad0c0c1e --- /dev/null +++ b/store/clientpayload_test.go @@ -0,0 +1,9 @@ +package store + +import "testing" + +func TestDevicePropsAdvertiseInlineContacts(t *testing.T) { + if !DeviceProps.GetHistorySyncConfig().GetSupportInlineContacts() { + t.Fatal("device properties do not advertise inline contact history") + } +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index c54a93840..4a35e72db 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -857,7 +857,8 @@ const ( ` putContactNamesQuery = ` INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name, username) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name, username=excluded.username + ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name, + username=CASE WHEN excluded.username <> '' THEN excluded.username ELSE whatsmeow_contacts.username END ` putContactUsernameQuery = ` INSERT INTO whatsmeow_contacts (our_jid, their_jid, username) VALUES ($1, $2, $3) diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 6a63f458a..1e0333a7a 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -83,6 +83,12 @@ func TestBuildSharedMassInsertQuery(t *testing.T) { } } +func TestBulkContactNamesPreserveMissingUsername(t *testing.T) { + if !strings.Contains(putContactNamesQuery, "CASE WHEN excluded.username <> '' THEN excluded.username ELSE whatsmeow_contacts.username END") { + t.Fatal("bulk contact-name update does not preserve an omitted username") + } +} + func TestIdentityCacheIsBounded(t *testing.T) { store := &SQLStore{identityCache: make(map[string]identityCacheEntry, maxIdentityCacheEntries)} for i := 0; i < maxIdentityCacheEntries; i++ { diff --git a/types/events/appstate.go b/types/events/appstate.go index 1e69a3e42..3dbb6d511 100644 --- a/types/events/appstate.go +++ b/types/events/appstate.go @@ -23,6 +23,15 @@ type Contact struct { FromFullSync bool // Whether the action is emitted because of a fullSync } +// LIDContact is emitted when a LID-keyed contact is modified from another device. +type LIDContact struct { + JID types.JID + Timestamp time.Time + + Action *waSyncAction.LidContactAction + FromFullSync bool +} + // PushName is emitted when a message is received with a different push name than the previous value cached for the same user. type PushName struct { JID types.JID // The user whose push name changed. diff --git a/username_contact_test.go b/username_contact_test.go index 7c7944d3f..be011d7c9 100644 --- a/username_contact_test.go +++ b/username_contact_test.go @@ -10,6 +10,7 @@ import ( "go.mau.fi/whatsmeow/proto/waSyncAction" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" ) func TestFilterContactsPreservesUsername(t *testing.T) { @@ -61,7 +62,7 @@ func TestDispatchLIDContactPersistsNamesAndUsername(t *testing.T) { contacts := &recordingLIDContactStore{} client := &Client{Store: &store.Device{Contacts: contacts}} lid := types.NewJID("100000011111111", types.HiddenUserServer) - client.dispatchAppState(context.Background(), appstate.WAPatchCriticalUnblockLow, appstate.Mutation{ + event := client.dispatchAppState(context.Background(), appstate.WAPatchCriticalUnblockLow, appstate.Mutation{ Index: []string{appstate.IndexLIDContact, lid.String()}, Action: &waSyncAction.SyncActionValue{LidContactAction: &waSyncAction.LidContactAction{ FullName: proto.String("LID User"), Username: proto.String("lid-example"), @@ -70,4 +71,8 @@ func TestDispatchLIDContactPersistsNamesAndUsername(t *testing.T) { if contacts.jid != lid || contacts.fullName != "LID User" || contacts.username != "lid-example" { t.Fatalf("unexpected persisted contact: %#v", contacts) } + lidEvent, ok := event.(*events.LIDContact) + if !ok || lidEvent.JID != lid || lidEvent.Action.GetUsername() != "lid-example" { + t.Fatalf("unexpected LID contact event: %#v", event) + } } From d38623460f2d39f093c3e1901f9b56ef51d51bf1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 12:00:40 +0300 Subject: [PATCH 094/163] fix: distinguish authoritative usernames --- appstate.go | 18 +++++++++-------- message.go | 9 +++++---- store/sqlstore/store.go | 39 +++++++++++++++++++++++++++++++++--- store/sqlstore/store_test.go | 13 ++++++++++-- store/store.go | 9 +++++---- username_contact_test.go | 3 +++ 6 files changed, 70 insertions(+), 21 deletions(-) diff --git a/appstate.go b/appstate.go index f964d3513..2da6a3726 100644 --- a/appstate.go +++ b/appstate.go @@ -227,19 +227,21 @@ func (cli *Client) filterContacts(mutations []appstate.Mutation) ([]appstate.Mut jid, _ := types.ParseJID(mutation.Index[1]) act := mutation.Action.GetContactAction() contacts = append(contacts, store.ContactEntry{ - JID: jid, - FirstName: act.GetFirstName(), - FullName: act.GetFullName(), - Username: act.GetUsername(), + JID: jid, + FirstName: act.GetFirstName(), + FullName: act.GetFullName(), + Username: act.GetUsername(), + UsernameSet: true, }) } else if len(mutation.Index) > 1 && mutation.Index[0] == appstate.IndexLIDContact { jid, _ := types.ParseJID(mutation.Index[1]) act := mutation.Action.GetLidContactAction() contacts = append(contacts, store.ContactEntry{ - JID: jid, - FirstName: act.GetFirstName(), - FullName: act.GetFullName(), - Username: act.GetUsername(), + JID: jid, + FirstName: act.GetFirstName(), + FullName: act.GetFullName(), + Username: act.GetUsername(), + UsernameSet: true, }) } else { filteredMutations = append(filteredMutations, mutation) diff --git a/message.go b/message.go index 0eaab1301..131de2e7b 100644 --- a/message.go +++ b/message.go @@ -861,10 +861,11 @@ func historicalInlineContactEntries(contacts []*waHistorySync.InlineContact) ([] continue } entries = append(entries, store.ContactEntry{ - JID: jid, - FirstName: contact.GetFirstName(), - FullName: contact.GetFullName(), - Username: contact.GetUsername(), + JID: jid, + FirstName: contact.GetFirstName(), + FullName: contact.GetFullName(), + Username: contact.GetUsername(), + UsernameSet: true, }) } return entries, mappings diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 4a35e72db..dfebf5c91 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -857,8 +857,11 @@ const ( ` putContactNamesQuery = ` INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name, username) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name, - username=CASE WHEN excluded.username <> '' THEN excluded.username ELSE whatsmeow_contacts.username END + ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name, username=excluded.username + ` + putContactNamesWithoutUsernameQuery = ` + INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name) VALUES ($1, $2, $3, $4) + ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name ` putContactUsernameQuery = ` INSERT INTO whatsmeow_contacts (our_jid, their_jid, username) VALUES ($1, $2, $3) @@ -889,6 +892,29 @@ var putContactNamesMassInsertBuilder = dbutil.NewMassInsertBuilder[store.Contact putContactNamesQuery, "($1, $%d, $%d, $%d, $%d)", ) +type contactNameEntryWithoutUsername store.ContactEntry + +func (entry contactNameEntryWithoutUsername) GetMassInsertValues() [3]any { + return [...]any{entry.JID.String(), entry.FirstName, entry.FullName} +} + +var putContactNamesWithoutUsernameMassInsertBuilder = dbutil.NewMassInsertBuilder[contactNameEntryWithoutUsername, [1]any]( + putContactNamesWithoutUsernameQuery, "($1, $%d, $%d, $%d)", +) + +func splitContactNameEntries(contacts []store.ContactEntry) ([]store.ContactEntry, []contactNameEntryWithoutUsername) { + withUsername := make([]store.ContactEntry, 0, len(contacts)) + withoutUsername := make([]contactNameEntryWithoutUsername, 0, len(contacts)) + for _, contact := range contacts { + if contact.UsernameSet || contact.Username != "" { + withUsername = append(withUsername, contact) + } else { + withoutUsername = append(withoutUsername, contactNameEntryWithoutUsername(contact)) + } + } + return withUsername, withoutUsername +} + var putRedactedPhonesMassInsertBuilder = dbutil.NewMassInsertBuilder[store.RedactedPhoneEntry, [1]any]( putRedactedPhoneQuery, "($1, $%d, $%d)", ) @@ -986,14 +1012,21 @@ func (s *SQLStore) PutAllContactNames(ctx context.Context, contacts []store.Cont if origLen != len(contacts) { s.log.Warnf("%d duplicate contacts found in PutAllContactNames", origLen-len(contacts)) } + withUsername, withoutUsername := splitContactNameEntries(contacts) err := s.db.DoTxn(ctx, nil, func(ctx context.Context) error { - for slice := range slices.Chunk(contacts, contactBatchSize) { + for slice := range slices.Chunk(withUsername, contactBatchSize) { query, vars := putContactNamesMassInsertBuilder.Build([1]any{s.JID}, slice) _, err := s.db.Exec(ctx, query, vars...) if err != nil { return err } } + for slice := range slices.Chunk(withoutUsername, contactBatchSize) { + query, vars := putContactNamesWithoutUsernameMassInsertBuilder.Build([1]any{s.JID}, slice) + if _, err := s.db.Exec(ctx, query, vars...); err != nil { + return err + } + } return nil }) if err != nil { diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 1e0333a7a..965300108 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/types" ) @@ -84,8 +85,16 @@ func TestBuildSharedMassInsertQuery(t *testing.T) { } func TestBulkContactNamesPreserveMissingUsername(t *testing.T) { - if !strings.Contains(putContactNamesQuery, "CASE WHEN excluded.username <> '' THEN excluded.username ELSE whatsmeow_contacts.username END") { - t.Fatal("bulk contact-name update does not preserve an omitted username") + withUsername, withoutUsername := splitContactNameEntries([]store.ContactEntry{ + {JID: types.NewJID("100", types.HiddenUserServer)}, + {JID: types.NewJID("101", types.HiddenUserServer), Username: "named"}, + {JID: types.NewJID("102", types.HiddenUserServer), UsernameSet: true}, + }) + if len(withoutUsername) != 1 || withoutUsername[0].JID.User != "100" { + t.Fatalf("name-only contacts = %#v", withoutUsername) + } + if len(withUsername) != 2 || withUsername[0].Username != "named" || !withUsername[1].UsernameSet || withUsername[1].Username != "" { + t.Fatalf("username-aware contacts = %#v", withUsername) } } diff --git a/store/store.go b/store/store.go index 62ad176af..32aa64cde 100644 --- a/store/store.go +++ b/store/store.go @@ -82,10 +82,11 @@ type AppStateStore interface { } type ContactEntry struct { - JID types.JID - FirstName string - FullName string - Username string + JID types.JID + FirstName string + FullName string + Username string + UsernameSet bool } func (ce ContactEntry) GetMassInsertValues() [4]any { diff --git a/username_contact_test.go b/username_contact_test.go index be011d7c9..f60e01205 100644 --- a/username_contact_test.go +++ b/username_contact_test.go @@ -37,6 +37,9 @@ func TestFilterContactsPreservesUsername(t *testing.T) { if contacts[0].Username != "example" || contacts[1].Username != "lid-example" { t.Fatalf("usernames = %q, %q", contacts[0].Username, contacts[1].Username) } + if !contacts[0].UsernameSet || !contacts[1].UsernameSet { + t.Fatal("snapshot usernames were not marked authoritative") + } } type recordingLIDContactStore struct { From fc28dc13f2719deba4b2fd598adb0d3ad450247e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 12:06:47 +0300 Subject: [PATCH 095/163] fix: preserve username migration compatibility --- store/sqlstore/upgrades/18-contact-username.sql | 2 +- store/sqlstore/upgrades/upgrades_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/store/sqlstore/upgrades/18-contact-username.sql b/store/sqlstore/upgrades/18-contact-username.sql index 828973d37..6d31cec7f 100644 --- a/store/sqlstore/upgrades/18-contact-username.sql +++ b/store/sqlstore/upgrades/18-contact-username.sql @@ -1,2 +1,2 @@ --- v18: Persist the optional WhatsApp username alongside the LID-keyed contact. +-- v18 (compatible with v8+): Persist the optional WhatsApp username alongside the LID-keyed contact. ALTER TABLE whatsmeow_contacts ADD COLUMN username TEXT; diff --git a/store/sqlstore/upgrades/upgrades_test.go b/store/sqlstore/upgrades/upgrades_test.go index 6f2b39cd5..e1bbe4e79 100644 --- a/store/sqlstore/upgrades/upgrades_test.go +++ b/store/sqlstore/upgrades/upgrades_test.go @@ -153,8 +153,8 @@ func TestUpgradeFromCurrentDevSchemaAddsUsername(t *testing.T) { if err = db.Upgrade(context.Background()); err != nil { t.Fatal(err) } - if state.version != 18 || state.compatVersion != 18 { - t.Fatalf("schema version = %d/%d, want 18/18", state.version, state.compatVersion) + if state.version != 18 || state.compatVersion != 8 { + t.Fatalf("schema version = %d/%d, want 18/8", state.version, state.compatVersion) } want := "ALTER TABLE whatsmeow_contacts ADD COLUMN username TEXT;" for _, query := range state.executed { From 83dd43302b6cddcb4fecc0b67495e4eb20f94bac Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 17:13:55 +0300 Subject: [PATCH 096/163] Batch username alias persistence --- store/sqlstore/store.go | 33 +++++++++++++++++++++++++++++++++ store/store.go | 10 ++++++++++ types/user.go | 1 + user.go | 33 +++++++++++++++++++++++++++------ username_resolution_test.go | 10 ++++++++++ 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index dfebf5c91..925ff4211 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -915,6 +915,10 @@ func splitContactNameEntries(contacts []store.ContactEntry) ([]store.ContactEntr return withUsername, withoutUsername } +var putContactUsernamesMassInsertBuilder = dbutil.NewMassInsertBuilder[store.ContactUsernameEntry, [1]any]( + putContactUsernameQuery, "($1, $%d, $%d)", +) + var putRedactedPhonesMassInsertBuilder = dbutil.NewMassInsertBuilder[store.RedactedPhoneEntry, [1]any]( putRedactedPhoneQuery, "($1, $%d, $%d)", ) @@ -999,6 +1003,35 @@ func (s *SQLStore) PutContactUsername(ctx context.Context, user types.JID, usern return nil } +func (s *SQLStore) PutManyContactUsernames(ctx context.Context, entries []store.ContactUsernameEntry) error { + if len(entries) == 0 { + return nil + } + entries = exslices.DeduplicateUnsortedOverwriteFunc(entries, func(entry store.ContactUsernameEntry) types.JID { + return entry.JID + }) + if err := s.db.DoTxn(ctx, nil, func(ctx context.Context) error { + for batch := range slices.Chunk(entries, contactBatchSize) { + query, vars := putContactUsernamesMassInsertBuilder.Build([1]any{s.JID}, batch) + if _, err := s.db.Exec(ctx, query, vars...); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + s.contactCacheLock.Lock() + for _, entry := range entries { + if cached, ok := s.contactCache[entry.JID]; ok { + cached.Username = entry.Username + cached.Found = true + } + } + s.contactCacheLock.Unlock() + return nil +} + const contactBatchSize = 300 func (s *SQLStore) PutAllContactNames(ctx context.Context, contacts []store.ContactEntry) error { diff --git a/store/store.go b/store/store.go index 32aa64cde..635109a5a 100644 --- a/store/store.go +++ b/store/store.go @@ -114,6 +114,16 @@ type ContactStore interface { type ContactUsernameStore interface { PutContactUsername(ctx context.Context, user types.JID, username string) error + PutManyContactUsernames(ctx context.Context, entries []ContactUsernameEntry) error +} + +type ContactUsernameEntry struct { + JID types.JID + Username string +} + +func (cue ContactUsernameEntry) GetMassInsertValues() [2]any { + return [...]any{cue.JID.String(), cue.Username} } var MutedForever = time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC) diff --git a/types/user.go b/types/user.go index a25243b08..611a879cb 100644 --- a/types/user.go +++ b/types/user.go @@ -99,6 +99,7 @@ type IsOnWhatsAppResponse struct { IsIn bool // Whether the phone is registered or not. PhoneNumber JID + Username string VerifiedName *VerifiedName // If the phone is a business, the verified business details. } diff --git a/user.go b/user.go index b1b4084fb..25da6c616 100644 --- a/user.go +++ b/user.go @@ -189,6 +189,7 @@ func (cli *Client) IsOnWhatsApp(ctx context.Context, phones []string) ([]types.I } output := make([]types.IsOnWhatsAppResponse, 0, len(jids)) lidEntries := make([]store.LIDMapping, 0, len(jids)) + usernameEntries := make([]store.ContactUsernameEntry, 0, len(jids)) querySuffix := "@" + types.LegacyUserServer for _, child := range list.GetChildren() { ag := child.AttrGetter() @@ -215,6 +216,10 @@ func (cli *Client) IsOnWhatsApp(ctx context.Context, phones []string) ([]types.I } contactNode := child.GetChildByTag("contact") info.IsIn = contactNode.AttrGetter().String("type") == "in" + info.Username = parseUSyncUsername(child) + if info.Username != "" && info.JID.Server == types.HiddenUserServer { + usernameEntries = append(usernameEntries, store.ContactUsernameEntry{JID: info.JID, Username: info.Username}) + } contactQuery, _ := contactNode.Content.([]byte) info.Query = strings.TrimSuffix(string(contactQuery), querySuffix) output = append(output, info) @@ -225,9 +230,22 @@ func (cli *Client) IsOnWhatsApp(ctx context.Context, phones []string) ([]types.I return output, fmt.Errorf("failed to store LID mappings: %w", err) } } + if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && len(usernameEntries) > 0 { + if err = usernameStore.PutManyContactUsernames(ctx, usernameEntries); err != nil { + return output, fmt.Errorf("failed to store usernames: %w", err) + } + } return output, nil } +func parseUSyncUsername(user waBinary.Node) string { + if username, ok := user.GetChildByTag("username").Content.([]byte); ok && len(username) > 0 { + return string(username) + } + contact := user.GetChildByTag("contact") + return contact.AttrGetter().OptionalString("username") +} + // GetUserInfo gets basic user info (avatar, status, verified business name, device list). func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types.JID]types.UserInfo, error) { list, err := cli.usync(ctx, jids, "full", "background", []waBinary.Node{ @@ -245,6 +263,7 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types } respData := make(map[types.JID]types.UserInfo, len(jids)) mappings := make([]store.LIDMapping, 0, len(jids)) + usernames := make([]store.ContactUsernameEntry, 0, len(jids)) for _, child := range list.GetChildren() { jid, jidOK := child.Attrs["jid"].(types.JID) if child.Tag != "user" || !jidOK { @@ -262,8 +281,7 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types lidTag := child.GetChildByTag("lid") info.LID = lidTag.AttrGetter().OptionalJIDOrEmpty("val") - username, _ := child.GetChildByTag("username").Content.([]byte) - info.Username = string(username) + info.Username = parseUSyncUsername(child) if !info.LID.IsEmpty() { mappings = append(mappings, store.LIDMapping{PN: jid, LID: info.LID}) @@ -273,14 +291,12 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types cli.updateBusinessName(ctx, jid, info.LID, nil, verifiedName.Details.GetVerifiedName()) } respData[jid] = info - if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && info.Username != "" { + if info.Username != "" { usernameJID := info.LID if usernameJID.IsEmpty() { usernameJID = jid } - if err := usernameStore.PutContactUsername(ctx, usernameJID, info.Username); err != nil { - cli.Log.Warnf("Failed to store username for %s: %v", usernameJID, err) - } + usernames = append(usernames, store.ContactUsernameEntry{JID: usernameJID, Username: info.Username}) } } @@ -289,6 +305,11 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types // not worth returning on the error, instead just post a log cli.Log.Errorf("Failed to place LID mappings from USync call") } + if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && len(usernames) > 0 { + if err := usernameStore.PutManyContactUsernames(ctx, usernames); err != nil { + cli.Log.Errorf("Failed to store usernames from USync call: %v", err) + } + } return respData, nil } diff --git a/username_resolution_test.go b/username_resolution_test.go index fe0a880cb..2fb34e26e 100644 --- a/username_resolution_test.go +++ b/username_resolution_test.go @@ -26,6 +26,16 @@ func TestParseUsernameResolution(t *testing.T) { } } +func TestParseUSyncUsernameFallsBackToContactAttribute(t *testing.T) { + user := waBinary.Node{Tag: "user", Content: []waBinary.Node{{ + Tag: "contact", + Attrs: waBinary.Attrs{"username": "example"}, + }}} + if got := parseUSyncUsername(user); got != "example" { + t.Fatalf("username = %q", got) + } +} + func TestParseUsernameResolutionDetectsRequiredKey(t *testing.T) { list := &waBinary.Node{Tag: "list", Content: []waBinary.Node{{ Tag: "user", From ace61269aa2a2fe532801fa22aff56101857f9ed Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 17:15:28 +0300 Subject: [PATCH 097/163] Expose username aliases in identity lookups --- group.go | 1 + group_username_test.go | 20 ++++++++++++++++++++ types/group.go | 1 + 3 files changed, 22 insertions(+) create mode 100644 group_username_test.go diff --git a/group.go b/group.go index 816729263..d5142a986 100644 --- a/group.go +++ b/group.go @@ -716,6 +716,7 @@ func parseParticipant(childAG *waBinary.AttrUtility, child *waBinary.Node) types IsSuperAdmin: pcpType == "superadmin", JID: childAG.JID("jid"), DisplayName: childAG.OptionalString("display_name"), + Username: childAG.OptionalString("username"), } if participant.JID.Server == types.HiddenUserServer { participant.LID = participant.JID diff --git a/group_username_test.go b/group_username_test.go new file mode 100644 index 000000000..c68c4348a --- /dev/null +++ b/group_username_test.go @@ -0,0 +1,20 @@ +package whatsmeow + +import ( + "testing" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/types" +) + +func TestParseGroupParticipantPreservesUsername(t *testing.T) { + node := &waBinary.Node{Tag: "participant", Attrs: waBinary.Attrs{ + "jid": types.NewJID("100000011111111", types.HiddenUserServer), + "username": "example", + }} + ag := node.AttrGetter() + participant := parseParticipant(ag, node) + if participant.Username != "example" { + t.Fatalf("username = %q", participant.Username) + } +} diff --git a/types/group.go b/types/group.go index 7de8df6af..925b6ff52 100644 --- a/types/group.go +++ b/types/group.go @@ -106,6 +106,7 @@ type GroupParticipant struct { JID JID PhoneNumber JID LID JID + Username string IsAdmin bool IsSuperAdmin bool From ea2d72ce2d2a0861f1f4275fa0f1dae1bf5f4e01 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:13:06 +0300 Subject: [PATCH 098/163] fix: persist username aliases compatibly --- group.go | 25 ++++++++++++++++ store/store.go | 3 ++ user.go | 31 ++++++++++++++------ username_persistence_test.go | 56 ++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 username_persistence_test.go diff --git a/group.go b/group.go index d5142a986..7a9875557 100644 --- a/group.go +++ b/group.go @@ -559,6 +559,7 @@ func (cli *Client) GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, err infos := make([]*types.GroupInfo, 0, len(children)) var allLIDPairs []store.LIDMapping var allRedactedPhones []store.RedactedPhoneEntry + var allUsernames []store.ContactUsernameEntry for _, child := range children { if child.Tag != "group" { cli.Log.Debugf("Unexpected child in group list response: %s", &child) @@ -571,6 +572,7 @@ func (cli *Client) GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, err lidPairs, redactedPhones := cli.cacheGroupInfo(parsed, true) allLIDPairs = append(allLIDPairs, lidPairs...) allRedactedPhones = append(allRedactedPhones, redactedPhones...) + allUsernames = append(allUsernames, groupContactUsernames(parsed)...) infos = append(infos, parsed) } err = cli.Store.LIDs.PutManyLIDMappings(ctx, allLIDPairs) @@ -581,6 +583,9 @@ func (cli *Client) GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, err if err != nil { cli.Log.Warnf("Failed to store redacted phones from joined groups: %v", err) } + if err = putContactUsernames(ctx, cli.Store.Contacts, allUsernames); err != nil { + cli.Log.Warnf("Failed to store usernames from joined groups: %v", err) + } return infos, nil } @@ -663,6 +668,23 @@ func (cli *Client) cacheGroupInfo(groupInfo *types.GroupInfo, lock bool) ([]stor return lidPairs, redactedPhones } +func groupContactUsernames(groupInfo *types.GroupInfo) []store.ContactUsernameEntry { + entries := make([]store.ContactUsernameEntry, 0, len(groupInfo.Participants)) + for _, participant := range groupInfo.Participants { + if participant.Username == "" { + continue + } + lid := participant.LID.ToNonAD() + if lid.IsEmpty() && participant.JID.Server == types.HiddenUserServer { + lid = participant.JID.ToNonAD() + } + if !lid.IsEmpty() { + entries = append(entries, store.ContactUsernameEntry{JID: lid, Username: participant.Username}) + } + } + return entries +} + func (cli *Client) getGroupInfo(ctx context.Context, jid types.JID, lockParticipantCache bool) (*types.GroupInfo, error) { res, err := cli.sendGroupIQ(ctx, iqGet, jid, waBinary.Node{ Tag: "query", @@ -693,6 +715,9 @@ func (cli *Client) getGroupInfo(ctx context.Context, jid types.JID, lockParticip if err != nil { cli.Log.Warnf("Failed to store redacted phones for members of %s: %v", jid, err) } + if err = putContactUsernames(ctx, cli.Store.Contacts, groupContactUsernames(groupInfo)); err != nil { + cli.Log.Warnf("Failed to store usernames for members of %s: %v", jid, err) + } return groupInfo, nil } diff --git a/store/store.go b/store/store.go index 635109a5a..73a9f90cf 100644 --- a/store/store.go +++ b/store/store.go @@ -114,6 +114,9 @@ type ContactStore interface { type ContactUsernameStore interface { PutContactUsername(ctx context.Context, user types.JID, username string) error +} + +type ContactUsernameBatchStore interface { PutManyContactUsernames(ctx context.Context, entries []ContactUsernameEntry) error } diff --git a/user.go b/user.go index 25da6c616..1595cd559 100644 --- a/user.go +++ b/user.go @@ -230,14 +230,31 @@ func (cli *Client) IsOnWhatsApp(ctx context.Context, phones []string) ([]types.I return output, fmt.Errorf("failed to store LID mappings: %w", err) } } - if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && len(usernameEntries) > 0 { - if err = usernameStore.PutManyContactUsernames(ctx, usernameEntries); err != nil { - return output, fmt.Errorf("failed to store usernames: %w", err) - } + if err = putContactUsernames(ctx, cli.Store.Contacts, usernameEntries); err != nil { + return output, fmt.Errorf("failed to store usernames: %w", err) } return output, nil } +func putContactUsernames(ctx context.Context, contacts store.ContactStore, entries []store.ContactUsernameEntry) error { + if len(entries) == 0 { + return nil + } + if batch, ok := contacts.(store.ContactUsernameBatchStore); ok { + return batch.PutManyContactUsernames(ctx, entries) + } + single, ok := contacts.(store.ContactUsernameStore) + if !ok { + return nil + } + for _, entry := range entries { + if err := single.PutContactUsername(ctx, entry.JID, entry.Username); err != nil { + return err + } + } + return nil +} + func parseUSyncUsername(user waBinary.Node) string { if username, ok := user.GetChildByTag("username").Content.([]byte); ok && len(username) > 0 { return string(username) @@ -305,10 +322,8 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types // not worth returning on the error, instead just post a log cli.Log.Errorf("Failed to place LID mappings from USync call") } - if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && len(usernames) > 0 { - if err := usernameStore.PutManyContactUsernames(ctx, usernames); err != nil { - cli.Log.Errorf("Failed to store usernames from USync call: %v", err) - } + if err := putContactUsernames(ctx, cli.Store.Contacts, usernames); err != nil { + cli.Log.Errorf("Failed to store usernames from USync call: %v", err) } return respData, nil diff --git a/username_persistence_test.go b/username_persistence_test.go new file mode 100644 index 000000000..1d229d93c --- /dev/null +++ b/username_persistence_test.go @@ -0,0 +1,56 @@ +package whatsmeow + +import ( + "context" + "testing" + + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" +) + +type singleUsernameStore struct { + store.NoopStore + entries []store.ContactUsernameEntry +} + +func (single *singleUsernameStore) PutContactUsername(_ context.Context, user types.JID, username string) error { + single.entries = append(single.entries, store.ContactUsernameEntry{JID: user, Username: username}) + return nil +} + +func TestContactUsernameStoreRemainsSingleWriteCompatible(t *testing.T) { + var contacts store.ContactStore = &singleUsernameStore{} + if _, ok := contacts.(store.ContactUsernameStore); !ok { + t.Fatal("single-write username store no longer satisfies ContactUsernameStore") + } +} + +func TestPutContactUsernamesFallsBackToSingleWrites(t *testing.T) { + contacts := &singleUsernameStore{} + entries := []store.ContactUsernameEntry{ + {JID: types.NewJID("100000011111111", types.HiddenUserServer), Username: "first"}, + {JID: types.NewJID("100000022222222", types.HiddenUserServer), Username: "second"}, + } + if err := putContactUsernames(context.Background(), contacts, entries); err != nil { + t.Fatal(err) + } + if len(contacts.entries) != len(entries) { + t.Fatalf("stored %d usernames, want %d", len(contacts.entries), len(entries)) + } + for index := range entries { + if contacts.entries[index] != entries[index] { + t.Fatalf("stored entry %d = %#v, want %#v", index, contacts.entries[index], entries[index]) + } + } +} + +func TestGroupContactUsernamesUseStableLIDs(t *testing.T) { + lid := types.NewJID("100000011111111", types.HiddenUserServer) + entries := groupContactUsernames(&types.GroupInfo{Participants: []types.GroupParticipant{ + {JID: lid, LID: lid, Username: "example"}, + {JID: types.NewJID("15550001111", types.DefaultUserServer), Username: "missing-lid"}, + }}) + if len(entries) != 1 || entries[0].JID != lid || entries[0].Username != "example" { + t.Fatalf("unexpected group username entries: %#v", entries) + } +} From af0478c9b2dd7e872a284c484b7b969b241287d5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:33:53 +0300 Subject: [PATCH 099/163] fix: serialize username batch writes --- store/sqlstore/store.go | 4 +- store/sqlstore/store_test.go | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 925ff4211..ed5c2f663 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -1010,6 +1010,8 @@ func (s *SQLStore) PutManyContactUsernames(ctx context.Context, entries []store. entries = exslices.DeduplicateUnsortedOverwriteFunc(entries, func(entry store.ContactUsernameEntry) types.JID { return entry.JID }) + s.contactCacheLock.Lock() + defer s.contactCacheLock.Unlock() if err := s.db.DoTxn(ctx, nil, func(ctx context.Context) error { for batch := range slices.Chunk(entries, contactBatchSize) { query, vars := putContactUsernamesMassInsertBuilder.Build([1]any{s.JID}, batch) @@ -1021,14 +1023,12 @@ func (s *SQLStore) PutManyContactUsernames(ctx context.Context, entries []store. }); err != nil { return err } - s.contactCacheLock.Lock() for _, entry := range entries { if cached, ok := s.contactCache[entry.JID]; ok { cached.Username = entry.Username cached.Found = true } } - s.contactCacheLock.Unlock() return nil } diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 965300108..e47995b09 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "strings" + "sync" "testing" "time" @@ -69,6 +70,44 @@ func (r *pnMigrationTestRows) Next(values []driver.Value) error { return nil } +type contactUsernameRaceDB struct { + once sync.Once + entered chan struct{} + release chan struct{} +} + +type contactUsernameRaceConnector struct{ state *contactUsernameRaceDB } + +func (connector *contactUsernameRaceConnector) Connect(context.Context) (driver.Conn, error) { + return &contactUsernameRaceConn{state: connector.state}, nil +} + +func (*contactUsernameRaceConnector) Driver() driver.Driver { return pnMigrationTestDriver{} } + +type contactUsernameRaceConn struct{ state *contactUsernameRaceDB } + +func (*contactUsernameRaceConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} + +func (*contactUsernameRaceConn) Close() error { return nil } +func (*contactUsernameRaceConn) Begin() (driver.Tx, error) { + return contactUsernameRaceTx{}, nil +} + +func (conn *contactUsernameRaceConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) { + conn.state.once.Do(func() { + close(conn.state.entered) + <-conn.state.release + }) + return driver.RowsAffected(1), nil +} + +type contactUsernameRaceTx struct{} + +func (contactUsernameRaceTx) Commit() error { return nil } +func (contactUsernameRaceTx) Rollback() error { return nil } + func TestNewSQLStoreDefersCaches(t *testing.T) { store := NewSQLStore(nil, types.JID{User: "123", Server: types.DefaultUserServer}) if store.contactCache != nil || store.identityCache != nil || store.migratedPNSessionsCache != nil || store.emptyPNMigrationCache != nil || store.migratingPNSessions != nil { @@ -130,6 +169,40 @@ func TestContactCacheIsBounded(t *testing.T) { } } +func TestPutManyContactUsernamesSerializesSingleWrites(t *testing.T) { + state := &contactUsernameRaceDB{entered: make(chan struct{}), release: make(chan struct{})} + db := sql.OpenDB(&contactUsernameRaceConnector{state: state}) + t.Cleanup(func() { _ = db.Close() }) + jid := types.NewJID("100000011111111", types.HiddenUserServer) + sqlStore := NewSQLStore(NewWithDB(db, "postgres", nil), types.NewJID("15550000000", types.DefaultUserServer)) + sqlStore.contactCache = map[types.JID]*types.ContactInfo{jid: {Found: true, Username: "initial"}} + + batchDone := make(chan error, 1) + go func() { + batchDone <- sqlStore.PutManyContactUsernames(context.Background(), []store.ContactUsernameEntry{{JID: jid, Username: "batch"}}) + }() + <-state.entered + singleDone := make(chan error, 1) + go func() { + singleDone <- sqlStore.PutContactUsername(context.Background(), jid, "single") + }() + select { + case err := <-singleDone: + t.Fatalf("single write overtook batch write: %v", err) + case <-time.After(50 * time.Millisecond): + } + close(state.release) + if err := <-batchDone; err != nil { + t.Fatal(err) + } + if err := <-singleDone; err != nil { + t.Fatal(err) + } + if got := sqlStore.contactCache[jid].Username; got != "single" { + t.Fatalf("cached username = %q, want single", got) + } +} + func TestMigratePNToLIDCachesEmptyPreflightTemporarily(t *testing.T) { state := &pnMigrationTestDB{} db := sql.OpenDB(&pnMigrationTestConnector{state: state}) From b42f258662d1ae5cff80cc45424e9f139a83ed9a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:04:01 +0300 Subject: [PATCH 100/163] fix: keep username persistence best-effort --- user.go | 10 +++++++--- username_persistence_test.go | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/user.go b/user.go index 1595cd559..3b90f8aa9 100644 --- a/user.go +++ b/user.go @@ -230,12 +230,16 @@ func (cli *Client) IsOnWhatsApp(ctx context.Context, phones []string) ([]types.I return output, fmt.Errorf("failed to store LID mappings: %w", err) } } - if err = putContactUsernames(ctx, cli.Store.Contacts, usernameEntries); err != nil { - return output, fmt.Errorf("failed to store usernames: %w", err) - } + cli.storeContactUsernamesBestEffort(ctx, usernameEntries) return output, nil } +func (cli *Client) storeContactUsernamesBestEffort(ctx context.Context, entries []store.ContactUsernameEntry) { + if err := putContactUsernames(ctx, cli.Store.Contacts, entries); err != nil { + cli.Log.Warnf("Failed to store usernames: %v", err) + } +} + func putContactUsernames(ctx context.Context, contacts store.ContactStore, entries []store.ContactUsernameEntry) error { if len(entries) == 0 { return nil diff --git a/username_persistence_test.go b/username_persistence_test.go index 1d229d93c..e97408969 100644 --- a/username_persistence_test.go +++ b/username_persistence_test.go @@ -2,10 +2,12 @@ package whatsmeow import ( "context" + "errors" "testing" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/types" + waLog "go.mau.fi/whatsmeow/util/log" ) type singleUsernameStore struct { @@ -13,6 +15,16 @@ type singleUsernameStore struct { entries []store.ContactUsernameEntry } +type failingUsernameStore struct { + store.NoopStore + called bool +} + +func (failing *failingUsernameStore) PutContactUsername(context.Context, types.JID, string) error { + failing.called = true + return errors.New("synthetic username cache failure") +} + func (single *singleUsernameStore) PutContactUsername(_ context.Context, user types.JID, username string) error { single.entries = append(single.entries, store.ContactUsernameEntry{JID: user, Username: username}) return nil @@ -44,6 +56,17 @@ func TestPutContactUsernamesFallsBackToSingleWrites(t *testing.T) { } } +func TestContactUsernamePersistenceIsBestEffort(t *testing.T) { + contacts := &failingUsernameStore{} + client := &Client{Store: &store.Device{Contacts: contacts}, Log: waLog.Noop} + client.storeContactUsernamesBestEffort(context.Background(), []store.ContactUsernameEntry{{ + JID: types.NewJID("100000011111111", types.HiddenUserServer), Username: "example", + }}) + if !contacts.called { + t.Fatal("username cache write was not attempted") + } +} + func TestGroupContactUsernamesUseStableLIDs(t *testing.T) { lid := types.NewJID("100000011111111", types.HiddenUserServer) entries := groupContactUsernames(&types.GroupInfo{Participants: []types.GroupParticipant{ From 8085653e1939518c4df5bbbb551cf4b63bb6501f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:17:19 +0300 Subject: [PATCH 101/163] fix: persist participant response usernames --- group.go | 10 ++++++++-- notification.go | 3 +++ username_persistence_test.go | 10 ++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/group.go b/group.go index 7a9875557..8d9a1ecdb 100644 --- a/group.go +++ b/group.go @@ -256,6 +256,7 @@ func (cli *Client) UpdateGroupParticipants(ctx context.Context, jid types.JID, p for i, child := range requestParticipants { participants[i] = parseParticipant(child.AttrGetter(), &child) } + cli.storeContactUsernamesBestEffort(ctx, groupParticipantUsernames(participants)) return participants, nil } @@ -321,6 +322,7 @@ func (cli *Client) UpdateGroupRequestParticipants(ctx context.Context, jid types for i, child := range requestParticipants { participants[i] = parseParticipant(child.AttrGetter(), &child) } + cli.storeContactUsernamesBestEffort(ctx, groupParticipantUsernames(participants)) return participants, nil } @@ -669,8 +671,12 @@ func (cli *Client) cacheGroupInfo(groupInfo *types.GroupInfo, lock bool) ([]stor } func groupContactUsernames(groupInfo *types.GroupInfo) []store.ContactUsernameEntry { - entries := make([]store.ContactUsernameEntry, 0, len(groupInfo.Participants)) - for _, participant := range groupInfo.Participants { + return groupParticipantUsernames(groupInfo.Participants) +} + +func groupParticipantUsernames(participants []types.GroupParticipant) []store.ContactUsernameEntry { + entries := make([]store.ContactUsernameEntry, 0, len(participants)) + for _, participant := range participants { if participant.Username == "" { continue } diff --git a/notification.go b/notification.go index 435a2b7be..68b3369ec 100644 --- a/notification.go +++ b/notification.go @@ -488,6 +488,9 @@ func (cli *Client) handleNotification(ctx context.Context, node *waBinary.Node) if err != nil { cli.Log.Warnf("Failed to store redacted phones from group notification: %v", err) } + if joined, ok := evt.(*events.JoinedGroup); ok { + cli.storeContactUsernamesBestEffort(ctx, groupContactUsernames(&joined.GroupInfo)) + } cancelled = cli.dispatchEvent(evt) } case "picture": diff --git a/username_persistence_test.go b/username_persistence_test.go index e97408969..43c131fb9 100644 --- a/username_persistence_test.go +++ b/username_persistence_test.go @@ -77,3 +77,13 @@ func TestGroupContactUsernamesUseStableLIDs(t *testing.T) { t.Fatalf("unexpected group username entries: %#v", entries) } } + +func TestGroupParticipantUsernamesUseStableLIDs(t *testing.T) { + lid := types.NewJID("100000011111111", types.HiddenUserServer) + entries := groupParticipantUsernames([]types.GroupParticipant{{ + JID: types.NewJID("15550001111", types.DefaultUserServer), LID: lid, Username: "example", + }}) + if len(entries) != 1 || entries[0].JID != lid || entries[0].Username != "example" { + t.Fatalf("unexpected participant username entries: %#v", entries) + } +} From 6ff94677f1fef8fbe88d96ed0d26237d89102e53 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 04:28:32 +0300 Subject: [PATCH 102/163] fix: continue username fallback writes --- user.go | 7 ++++--- username_persistence_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/user.go b/user.go index 3b90f8aa9..748424290 100644 --- a/user.go +++ b/user.go @@ -251,12 +251,13 @@ func putContactUsernames(ctx context.Context, contacts store.ContactStore, entri if !ok { return nil } + var firstErr error for _, entry := range entries { - if err := single.PutContactUsername(ctx, entry.JID, entry.Username); err != nil { - return err + if err := single.PutContactUsername(ctx, entry.JID, entry.Username); err != nil && firstErr == nil { + firstErr = err } } - return nil + return firstErr } func parseUSyncUsername(user waBinary.Node) string { diff --git a/username_persistence_test.go b/username_persistence_test.go index 43c131fb9..adb27a92b 100644 --- a/username_persistence_test.go +++ b/username_persistence_test.go @@ -20,6 +20,19 @@ type failingUsernameStore struct { called bool } +type partiallyFailingUsernameStore struct { + store.NoopStore + entries []store.ContactUsernameEntry +} + +func (partial *partiallyFailingUsernameStore) PutContactUsername(_ context.Context, user types.JID, username string) error { + if username == "first" { + return errors.New("synthetic first-write failure") + } + partial.entries = append(partial.entries, store.ContactUsernameEntry{JID: user, Username: username}) + return nil +} + func (failing *failingUsernameStore) PutContactUsername(context.Context, types.JID, string) error { failing.called = true return errors.New("synthetic username cache failure") @@ -56,6 +69,21 @@ func TestPutContactUsernamesFallsBackToSingleWrites(t *testing.T) { } } +func TestPutContactUsernamesContinuesAfterSingleWriteFailure(t *testing.T) { + contacts := &partiallyFailingUsernameStore{} + second := store.ContactUsernameEntry{JID: types.NewJID("100000022222222", types.HiddenUserServer), Username: "second"} + err := putContactUsernames(context.Background(), contacts, []store.ContactUsernameEntry{ + {JID: types.NewJID("100000011111111", types.HiddenUserServer), Username: "first"}, + second, + }) + if err == nil { + t.Fatal("single-write failure was not returned") + } + if len(contacts.entries) != 1 || contacts.entries[0] != second { + t.Fatalf("writes after the first failure were skipped: %#v", contacts.entries) + } +} + func TestContactUsernamePersistenceIsBestEffort(t *testing.T) { contacts := &failingUsernameStore{} client := &Client{Store: &store.Device{Contacts: contacts}, Log: waLog.Noop} From 869dfa3d499dbd40fde0304da7021cca756b33c6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:10:59 +0300 Subject: [PATCH 103/163] fix(groups): persist aliases from direct responses --- group.go | 15 ++++++++++++--- username_persistence_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/group.go b/group.go index 8d9a1ecdb..87d23c79f 100644 --- a/group.go +++ b/group.go @@ -157,7 +157,7 @@ func (cli *Client) CreateGroup(ctx context.Context, req ReqCreateGroup) (*types. if !ok { return nil, &ElementMissingError{Tag: "group", In: "response to create group query"} } - return cli.parseGroupNode(&groupNode) + return cli.parseGroupNodeAndStoreUsernames(ctx, &groupNode) } // UnlinkGroup removes a child group from a parent community. @@ -474,7 +474,7 @@ func (cli *Client) GetGroupInfoFromInvite(ctx context.Context, jid, inviter type if !ok { return nil, &ElementMissingError{Tag: "group", In: "response to invite group info query"} } - return cli.parseGroupNode(&groupNode) + return cli.parseGroupNodeAndStoreUsernames(ctx, &groupNode) } // JoinGroupWithInvite joins a group using an invite message. @@ -512,7 +512,7 @@ func (cli *Client) GetGroupInfoFromLink(ctx context.Context, code string) (*type if !ok { return nil, &ElementMissingError{Tag: "group", In: "response to group link info query"} } - return cli.parseGroupNode(&groupNode) + return cli.parseGroupNodeAndStoreUsernames(ctx, &groupNode) } // JoinGroupWithLink joins the group using the given invite link. @@ -674,6 +674,15 @@ func groupContactUsernames(groupInfo *types.GroupInfo) []store.ContactUsernameEn return groupParticipantUsernames(groupInfo.Participants) } +func (cli *Client) parseGroupNodeAndStoreUsernames(ctx context.Context, groupNode *waBinary.Node) (*types.GroupInfo, error) { + groupInfo, err := cli.parseGroupNode(groupNode) + if err != nil { + return groupInfo, err + } + cli.storeContactUsernamesBestEffort(ctx, groupContactUsernames(groupInfo)) + return groupInfo, nil +} + func groupParticipantUsernames(participants []types.GroupParticipant) []store.ContactUsernameEntry { entries := make([]store.ContactUsernameEntry, 0, len(participants)) for _, participant := range participants { diff --git a/username_persistence_test.go b/username_persistence_test.go index adb27a92b..1af7552be 100644 --- a/username_persistence_test.go +++ b/username_persistence_test.go @@ -5,6 +5,7 @@ import ( "errors" "testing" + waBinary "go.mau.fi/whatsmeow/binary" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/types" waLog "go.mau.fi/whatsmeow/util/log" @@ -115,3 +116,27 @@ func TestGroupParticipantUsernamesUseStableLIDs(t *testing.T) { t.Fatalf("unexpected participant username entries: %#v", entries) } } + +func TestParseGroupResponsePersistsParticipantUsernames(t *testing.T) { + contacts := &singleUsernameStore{} + client := &Client{Store: &store.Device{Contacts: contacts}, Log: waLog.Noop} + lid := types.NewJID("100000011111111", types.HiddenUserServer) + groupNode := &waBinary.Node{ + Tag: "group", + Attrs: waBinary.Attrs{"id": "120363000000000000"}, + Content: []waBinary.Node{{ + Tag: "participant", + Attrs: waBinary.Attrs{"jid": lid, "username": "example"}, + }}, + } + info, err := client.parseGroupNodeAndStoreUsernames(context.Background(), groupNode) + if err != nil { + t.Fatal(err) + } + if len(info.Participants) != 1 || len(contacts.entries) != 1 { + t.Fatalf("parsed participants = %d, stored usernames = %#v", len(info.Participants), contacts.entries) + } + if contacts.entries[0].JID != lid || contacts.entries[0].Username != "example" { + t.Fatalf("stored username = %#v", contacts.entries[0]) + } +} From 291af6fce53f3d1fe1395e5bd7e6d763c5a4f016 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:25:13 +0300 Subject: [PATCH 104/163] fix(groups): persist aliases from membership changes --- group.go | 69 +++++++++++++++++++++++++----------- notification.go | 6 ++-- username_persistence_test.go | 22 ++++++++++++ 3 files changed, 73 insertions(+), 24 deletions(-) diff --git a/group.go b/group.go index 87d23c79f..83dbcc027 100644 --- a/group.go +++ b/group.go @@ -624,7 +624,7 @@ func (cli *Client) GetLinkedGroupsParticipants(ctx context.Context, community ty if !ok { return nil, &ElementMissingError{Tag: "linked_groups_participants", In: "response to community participants query"} } - members, lidPairs := parseParticipantList(&participants) + members, lidPairs, _ := parseParticipantList(&participants) if len(lidPairs) > 0 { err = cli.Store.LIDs.PutManyLIDMappings(ctx, lidPairs) if err != nil { @@ -867,7 +867,7 @@ func parseGroupLinkTargetNode(groupNode *waBinary.Node) (types.GroupLinkTarget, }, ag.Error() } -func parseParticipantList(node *waBinary.Node) (participants []types.JID, lidPairs []store.LIDMapping) { +func parseParticipantList(node *waBinary.Node) (participants []types.JID, lidPairs []store.LIDMapping, usernames []store.ContactUsernameEntry) { children := node.GetChildren() participants = make([]types.JID, 0, len(children)) for _, child := range children { @@ -876,6 +876,8 @@ func parseParticipantList(node *waBinary.Node) (participants []types.JID, lidPai continue } participants = append(participants, jid) + participant := parseParticipant(child.AttrGetter(), &child) + usernames = append(usernames, groupParticipantUsernames([]types.GroupParticipant{participant})...) if jid.Server == types.HiddenUserServer { phoneNumber, ok := child.Attrs["phone_number"].(types.JID) if ok && !phoneNumber.IsEmpty() { @@ -923,7 +925,7 @@ func (cli *Client) parseGroupCreate(parentNode, node *waBinary.Node) (*events.Jo return &evt, lidPairs, redactedPhones, nil } -func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []store.LIDMapping, error) { +func (cli *Client) parseGroupChangeWithUsernames(node *waBinary.Node) (*events.GroupInfo, []store.LIDMapping, []store.ContactUsernameEntry, error) { var evt events.GroupInfo ag := node.AttrGetter() evt.JID = ag.JID("from") @@ -932,10 +934,11 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s evt.SenderPN = ag.OptionalJID("participant_pn") evt.Timestamp = ag.UnixTime("t") if !ag.OK() { - return nil, nil, fmt.Errorf("group change doesn't contain required attributes: %w", ag.Error()) + return nil, nil, nil, fmt.Errorf("group change doesn't contain required attributes: %w", ag.Error()) } var lidPairs []store.LIDMapping + var usernames []store.ContactUsernameEntry for _, child := range node.GetChildren() { cag := child.AttrGetter() if child.Tag == "add" || child.Tag == "remove" || child.Tag == "promote" || child.Tag == "demote" { @@ -945,13 +948,25 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s switch child.Tag { case "add": evt.JoinReason = cag.OptionalString("reason") - evt.Join, lidPairs = parseParticipantList(&child) + participants, pairs, names := parseParticipantList(&child) + evt.Join = participants + lidPairs = append(lidPairs, pairs...) + usernames = append(usernames, names...) case "remove": - evt.Leave, lidPairs = parseParticipantList(&child) + participants, pairs, names := parseParticipantList(&child) + evt.Leave = participants + lidPairs = append(lidPairs, pairs...) + usernames = append(usernames, names...) case "promote": - evt.Promote, lidPairs = parseParticipantList(&child) + participants, pairs, names := parseParticipantList(&child) + evt.Promote = participants + lidPairs = append(lidPairs, pairs...) + usernames = append(usernames, names...) case "demote": - evt.Demote, lidPairs = parseParticipantList(&child) + participants, pairs, names := parseParticipantList(&child) + evt.Demote = participants + lidPairs = append(lidPairs, pairs...) + usernames = append(usernames, names...) case "locked": evt.Locked = &types.GroupLocked{IsLocked: true} case "unlocked": @@ -972,7 +987,7 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s topicChild := child.GetChildByTag("body") topicBytes, ok := topicChild.Content.([]byte) if !ok { - return nil, nil, fmt.Errorf("group change description has unexpected body: %s", &topicChild) + return nil, nil, nil, fmt.Errorf("group change description has unexpected body: %s", &topicChild) } topicStr = string(topicBytes) } @@ -1014,12 +1029,12 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s } groupNode, ok := child.GetOptionalChildByTag("group") if !ok { - return nil, nil, &ElementMissingError{Tag: "group", In: "group link"} + return nil, nil, nil, &ElementMissingError{Tag: "group", In: "group link"} } var err error evt.Link.Group, err = parseGroupLinkTargetNode(&groupNode) if err != nil { - return nil, nil, fmt.Errorf("failed to parse group link node in group change: %w", err) + return nil, nil, nil, fmt.Errorf("failed to parse group link node in group change: %w", err) } case "unlink": evt.Unlink = &types.GroupLinkChange{ @@ -1028,12 +1043,12 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s } groupNode, ok := child.GetOptionalChildByTag("group") if !ok { - return nil, nil, &ElementMissingError{Tag: "group", In: "group unlink"} + return nil, nil, nil, &ElementMissingError{Tag: "group", In: "group unlink"} } var err error evt.Unlink.Group, err = parseGroupLinkTargetNode(&groupNode) if err != nil { - return nil, nil, fmt.Errorf("failed to parse group unlink node in group change: %w", err) + return nil, nil, nil, fmt.Errorf("failed to parse group unlink node in group change: %w", err) } case "membership_approval_mode": evt.MembershipApprovalMode = &types.GroupMembershipApprovalMode{ @@ -1047,10 +1062,15 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s evt.UnknownChanges = append(evt.UnknownChanges, &child) } if !cag.OK() { - return nil, nil, fmt.Errorf("group change %s element doesn't contain required attributes: %w", child.Tag, cag.Error()) + return nil, nil, nil, fmt.Errorf("group change %s element doesn't contain required attributes: %w", child.Tag, cag.Error()) } } - return &evt, lidPairs, nil + return &evt, lidPairs, usernames, nil +} + +func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []store.LIDMapping, error) { + event, lidPairs, _, err := cli.parseGroupChangeWithUsernames(node) + return event, lidPairs, err } func (cli *Client) updateGroupParticipantCache(evt *events.GroupInfo) { @@ -1084,20 +1104,29 @@ Outer: } } -func (cli *Client) parseGroupNotification(node *waBinary.Node) (any, []store.LIDMapping, []store.RedactedPhoneEntry, error) { +func (cli *Client) parseGroupNotificationWithUsernames(node *waBinary.Node) (any, []store.LIDMapping, []store.RedactedPhoneEntry, []store.ContactUsernameEntry, error) { children := node.GetChildren() if len(children) == 1 && children[0].Tag == "create" { - return cli.parseGroupCreate(node, &children[0]) + event, lidPairs, redactedPhones, err := cli.parseGroupCreate(node, &children[0]) + if err != nil { + return nil, nil, nil, nil, err + } + return event, lidPairs, redactedPhones, groupContactUsernames(&event.GroupInfo), nil } else { - groupChange, lidPairs, err := cli.parseGroupChange(node) + groupChange, lidPairs, usernames, err := cli.parseGroupChangeWithUsernames(node) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } cli.updateGroupParticipantCache(groupChange) - return groupChange, lidPairs, nil, nil + return groupChange, lidPairs, nil, usernames, nil } } +func (cli *Client) parseGroupNotification(node *waBinary.Node) (any, []store.LIDMapping, []store.RedactedPhoneEntry, error) { + event, lidPairs, redactedPhones, _, err := cli.parseGroupNotificationWithUsernames(node) + return event, lidPairs, redactedPhones, err +} + // SetGroupJoinApprovalMode sets the group join approval mode to 'on' or 'off'. func (cli *Client) SetGroupJoinApprovalMode(ctx context.Context, jid types.JID, mode bool) error { modeStr := "off" diff --git a/notification.go b/notification.go index 68b3369ec..49eb69d42 100644 --- a/notification.go +++ b/notification.go @@ -476,7 +476,7 @@ func (cli *Client) handleNotification(ctx context.Context, node *waBinary.Node) case "fbid:devices": cli.handleFBDeviceNotification(ctx, node) case "w:gp2": - evt, lidPairs, redactedPhones, err := cli.parseGroupNotification(node) + evt, lidPairs, redactedPhones, usernames, err := cli.parseGroupNotificationWithUsernames(node) if err != nil { cli.Log.Errorf("Failed to parse group notification: %v", err) } else { @@ -488,9 +488,7 @@ func (cli *Client) handleNotification(ctx context.Context, node *waBinary.Node) if err != nil { cli.Log.Warnf("Failed to store redacted phones from group notification: %v", err) } - if joined, ok := evt.(*events.JoinedGroup); ok { - cli.storeContactUsernamesBestEffort(ctx, groupContactUsernames(&joined.GroupInfo)) - } + cli.storeContactUsernamesBestEffort(ctx, usernames) cancelled = cli.dispatchEvent(evt) } case "picture": diff --git a/username_persistence_test.go b/username_persistence_test.go index 1af7552be..86b1aac26 100644 --- a/username_persistence_test.go +++ b/username_persistence_test.go @@ -140,3 +140,25 @@ func TestParseGroupResponsePersistsParticipantUsernames(t *testing.T) { t.Fatalf("stored username = %#v", contacts.entries[0]) } } + +func TestParseGroupChangeReturnsParticipantUsernames(t *testing.T) { + lid := types.NewJID("100000011111111", types.HiddenUserServer) + node := &waBinary.Node{ + Tag: "notification", + Attrs: waBinary.Attrs{"from": types.NewJID("120363000000000000", types.GroupServer), "t": "1"}, + Content: []waBinary.Node{{ + Tag: "add", + Content: []waBinary.Node{{ + Tag: "participant", + Attrs: waBinary.Attrs{"jid": lid, "username": "example"}, + }}, + }}, + } + _, _, usernames, err := (&Client{}).parseGroupChangeWithUsernames(node) + if err != nil { + t.Fatal(err) + } + if len(usernames) != 1 || usernames[0].JID != lid || usernames[0].Username != "example" { + t.Fatalf("group-change usernames = %#v", usernames) + } +} From ee92fe79d053770404ac21af5ff7cb5904312c03 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:33:45 +0300 Subject: [PATCH 105/163] fix(groups): persist linked-community aliases --- group.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/group.go b/group.go index 83dbcc027..e141b5135 100644 --- a/group.go +++ b/group.go @@ -624,13 +624,14 @@ func (cli *Client) GetLinkedGroupsParticipants(ctx context.Context, community ty if !ok { return nil, &ElementMissingError{Tag: "linked_groups_participants", In: "response to community participants query"} } - members, lidPairs, _ := parseParticipantList(&participants) + members, lidPairs, usernames := parseParticipantList(&participants) if len(lidPairs) > 0 { err = cli.Store.LIDs.PutManyLIDMappings(ctx, lidPairs) if err != nil { cli.Log.Warnf("Failed to store LID mappings for community participants: %v", err) } } + cli.storeContactUsernamesBestEffort(ctx, usernames) return members, nil } From 11124b762fe8014f092d0a243cd75642b1e3499d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 12:18:23 +0300 Subject: [PATCH 106/163] fix(groups): persist pending-request aliases --- group.go | 20 +++++++++++++++----- username_persistence_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/group.go b/group.go index e141b5135..c9b6b861a 100644 --- a/group.go +++ b/group.go @@ -273,14 +273,24 @@ func (cli *Client) GetGroupRequestParticipants(ctx context.Context, jid types.JI return nil, &ElementMissingError{Tag: "membership_approval_requests", In: "response to group request participants query"} } requestParticipants := request.GetChildrenByTag("membership_approval_request") - participants := make([]types.GroupParticipantRequest, len(requestParticipants)) - for i, req := range requestParticipants { + participants, usernames := parseGroupParticipantRequests(requestParticipants) + cli.storeContactUsernamesBestEffort(ctx, usernames) + return participants, nil +} + +func parseGroupParticipantRequests(nodes []waBinary.Node) ([]types.GroupParticipantRequest, []store.ContactUsernameEntry) { + participants := make([]types.GroupParticipantRequest, len(nodes)) + usernames := make([]store.ContactUsernameEntry, 0, len(nodes)) + for i := range nodes { + ag := nodes[i].AttrGetter() + participant := parseParticipant(ag, &nodes[i]) participants[i] = types.GroupParticipantRequest{ - JID: req.AttrGetter().JID("jid"), - RequestedAt: req.AttrGetter().UnixTime("request_time"), + JID: participant.JID, + RequestedAt: ag.UnixTime("request_time"), } + usernames = append(usernames, groupParticipantUsernames([]types.GroupParticipant{participant})...) } - return participants, nil + return participants, usernames } type ParticipantRequestChange string diff --git a/username_persistence_test.go b/username_persistence_test.go index 86b1aac26..18ac80335 100644 --- a/username_persistence_test.go +++ b/username_persistence_test.go @@ -162,3 +162,30 @@ func TestParseGroupChangeReturnsParticipantUsernames(t *testing.T) { t.Fatalf("group-change usernames = %#v", usernames) } } + +func TestParseGroupParticipantRequestsReturnsUsernamesByStableLID(t *testing.T) { + lid := types.NewJID("100000011111111", types.HiddenUserServer) + pn := types.NewJID("15550001111", types.DefaultUserServer) + nodes := []waBinary.Node{ + {Tag: "membership_approval_request", Attrs: waBinary.Attrs{ + "jid": lid, "username": "lid-user", "request_time": "1", + }}, + {Tag: "membership_approval_request", Attrs: waBinary.Attrs{ + "jid": pn, "lid": lid, "username": "pn-user", "request_time": "2", + }}, + } + + requests, usernames := parseGroupParticipantRequests(nodes) + if len(requests) != 2 || requests[0].JID != lid || requests[1].JID != pn { + t.Fatalf("participant requests = %#v", requests) + } + if len(usernames) != 2 { + t.Fatalf("usernames = %#v", usernames) + } + if usernames[0].JID != lid || usernames[0].Username != "lid-user" { + t.Fatalf("LID-addressed username = %#v", usernames[0]) + } + if usernames[1].JID != lid || usernames[1].Username != "pn-user" { + t.Fatalf("PN-addressed username = %#v", usernames[1]) + } +} From f0f67752101a53401d04e065b6a6df52fbd4f1ab Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 12:24:53 +0300 Subject: [PATCH 107/163] fix(store): keep latest batched username --- store/sqlstore/store.go | 19 ++++++++++++++++--- store/sqlstore/store_test.go | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index ed5c2f663..327a3cfe5 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -1007,9 +1007,7 @@ func (s *SQLStore) PutManyContactUsernames(ctx context.Context, entries []store. if len(entries) == 0 { return nil } - entries = exslices.DeduplicateUnsortedOverwriteFunc(entries, func(entry store.ContactUsernameEntry) types.JID { - return entry.JID - }) + entries = deduplicateContactUsernamesLastWriteWins(entries) s.contactCacheLock.Lock() defer s.contactCacheLock.Unlock() if err := s.db.DoTxn(ctx, nil, func(ctx context.Context) error { @@ -1032,6 +1030,21 @@ func (s *SQLStore) PutManyContactUsernames(ctx context.Context, entries []store. return nil } +func deduplicateContactUsernamesLastWriteWins(entries []store.ContactUsernameEntry) []store.ContactUsernameEntry { + positions := make(map[types.JID]int, len(entries)) + out := entries[:0] + for _, entry := range entries { + if position, ok := positions[entry.JID]; ok { + out[position] = entry + } else { + positions[entry.JID] = len(out) + out = append(out, entry) + } + } + clear(entries[len(out):]) + return out +} + const contactBatchSize = 300 func (s *SQLStore) PutAllContactNames(ctx context.Context, contacts []store.ContactEntry) error { diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index e47995b09..6998d9255 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -203,6 +203,25 @@ func TestPutManyContactUsernamesSerializesSingleWrites(t *testing.T) { } } +func TestDeduplicateContactUsernamesUsesLastAlias(t *testing.T) { + firstJID := types.NewJID("100000011111111", types.HiddenUserServer) + secondJID := types.NewJID("100000022222222", types.HiddenUserServer) + entries := deduplicateContactUsernamesLastWriteWins([]store.ContactUsernameEntry{ + {JID: firstJID, Username: "old"}, + {JID: secondJID, Username: "other"}, + {JID: firstJID, Username: "new"}, + }) + if len(entries) != 2 { + t.Fatalf("deduplicated entries = %#v", entries) + } + if entries[0].JID != firstJID || entries[0].Username != "new" { + t.Fatalf("first entry = %#v, want latest alias for %s", entries[0], firstJID) + } + if entries[1].JID != secondJID || entries[1].Username != "other" { + t.Fatalf("second entry = %#v", entries[1]) + } +} + func TestMigratePNToLIDCachesEmptyPreflightTemporarily(t *testing.T) { state := &pnMigrationTestDB{} db := sql.OpenDB(&pnMigrationTestConnector{state: state}) From f772cc7d5eaee7bf801a73e48773798e57c43b2d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 17:39:16 +0300 Subject: [PATCH 108/163] Batch reverse LID alias lookups --- store/noop.go | 4 +++ store/sqlstore/lidmap.go | 59 +++++++++++++++++++++++++++++++++++ store/sqlstore/lidmap_test.go | 25 +++++++++++++++ store/store.go | 1 + 4 files changed, 89 insertions(+) diff --git a/store/noop.go b/store/noop.go index 0afbfa785..80a7c8f00 100644 --- a/store/noop.go +++ b/store/noop.go @@ -281,6 +281,10 @@ func (n *NoopStore) GetManyLIDsForPNs(ctx context.Context, pns []types.JID) (map return nil, n.Error } +func (n *NoopStore) GetManyPNsForLIDs(ctx context.Context, lids []types.JID) (map[types.JID]types.JID, error) { + return nil, n.Error +} + func (n *NoopStore) GetPNForLID(ctx context.Context, lid types.JID) (types.JID, error) { return types.JID{}, n.Error } diff --git a/store/sqlstore/lidmap.go b/store/sqlstore/lidmap.go index 793f7114e..a37a69989 100644 --- a/store/sqlstore/lidmap.go +++ b/store/sqlstore/lidmap.go @@ -243,6 +243,65 @@ func (s *CachedLIDMap) GetManyLIDsForPNs(ctx context.Context, pns []types.JID) ( return result, err } +func (s *CachedLIDMap) GetManyPNsForLIDs(ctx context.Context, lids []types.JID) (map[types.JID]types.JID, error) { + if len(lids) == 0 { + return nil, nil + } + + result := make(map[types.JID]types.JID, len(lids)) + + s.lidCacheLock.RLock() + missingLIDs := make([]string, 0, len(lids)) + missingLIDDevices := make(map[string][]types.JID) + for _, lid := range lids { + if lid.Server != types.HiddenUserServer { + continue + } + if pnUser, ok := s.lidToPNCache[lid.User]; ok && pnUser != "" { + result[lid] = types.JID{User: pnUser, Device: lid.Device, Server: types.DefaultUserServer} + } else if !s.cacheFilled { + missingLIDs = append(missingLIDs, lid.User) + missingLIDDevices[lid.User] = append(missingLIDDevices[lid.User], lid) + } + } + s.lidCacheLock.RUnlock() + + if len(missingLIDs) == 0 { + return result, nil + } + + s.lidCacheLock.Lock() + defer s.lidCacheLock.Unlock() + + var res dbutil.RowIter[store.LIDMapping] + if wrapped, ok := wrapPostgresArray(s.db, missingLIDs); ok { + res = convertLIDRow.NewRowIter(s.db.Query( + ctx, + `SELECT lid, pn FROM whatsmeow_lid_map WHERE lid = ANY($1)`, + wrapped, + )) + } else { + placeholders := make([]string, len(missingLIDs)) + for i := range missingLIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + } + res = convertLIDRow.NewRowIter(s.db.Query( + ctx, + fmt.Sprintf(`SELECT lid, pn FROM whatsmeow_lid_map WHERE lid IN (%s)`, strings.Join(placeholders, ",")), + exslices.CastToAny(missingLIDs)..., + )) + } + _, err := s.scanManyLids(res, func(lid, pn string) { + for _, dev := range missingLIDDevices[lid] { + pnDev := dev + pnDev.Server = types.DefaultUserServer + pnDev.User = pn + result[dev] = pnDev + } + }) + return result, err +} + func (s *CachedLIDMap) PutLIDMapping(ctx context.Context, lid, pn types.JID) error { if lid.Server != types.HiddenUserServer || pn.Server != types.DefaultUserServer { return fmt.Errorf("invalid PutLIDMapping call %s/%s", lid, pn) diff --git a/store/sqlstore/lidmap_test.go b/store/sqlstore/lidmap_test.go index 3b98d3a4b..94b01124c 100644 --- a/store/sqlstore/lidmap_test.go +++ b/store/sqlstore/lidmap_test.go @@ -1,8 +1,11 @@ package sqlstore import ( + "context" "fmt" "testing" + + "go.mau.fi/whatsmeow/types" ) func TestLIDCacheIsBounded(t *testing.T) { @@ -26,3 +29,25 @@ func TestLIDCacheIsBounded(t *testing.T) { } } } + +func TestGetManyPNsForLIDsUsesReverseCache(t *testing.T) { + cache := NewCachedLIDMap(nil) + cache.cacheFilled = true + cache.cacheMappingLocked("100000000000001", "15550000001") + cache.cacheMappingLocked("100000000000002", "15550000002") + + lids := []types.JID{ + types.NewJID("100000000000001", types.HiddenUserServer), + types.NewJID("100000000000002", types.HiddenUserServer), + } + got, err := cache.GetManyPNsForLIDs(context.Background(), lids) + if err != nil { + t.Fatal(err) + } + for i, lid := range lids { + want := fmt.Sprintf("1555000000%d@s.whatsapp.net", i+1) + if got[lid].String() != want { + t.Fatalf("phone for %s = %s, want %s", lid, got[lid], want) + } + } +} diff --git a/store/store.go b/store/store.go index 73a9f90cf..893ee2ed4 100644 --- a/store/store.go +++ b/store/store.go @@ -208,6 +208,7 @@ type LIDStore interface { GetPNForLID(ctx context.Context, lid types.JID) (types.JID, error) GetLIDForPN(ctx context.Context, pn types.JID) (types.JID, error) GetManyLIDsForPNs(ctx context.Context, pns []types.JID) (map[types.JID]types.JID, error) + GetManyPNsForLIDs(ctx context.Context, lids []types.JID) (map[types.JID]types.JID, error) } type AllSessionSpecificStores interface { From 2d41474646f99e4bf74d1a56da5661b6d825ca3d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 12:37:49 +0300 Subject: [PATCH 109/163] fix(store): preserve reverse lookup compatibility --- store/sqlstore/lidmap.go | 40 +++++++++++++++++++++++--- store/sqlstore/lidmap_test.go | 54 +++++++++++++++++++++++++++++++++++ store/store.go | 3 ++ store/store_test.go | 19 ++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/store/sqlstore/lidmap.go b/store/sqlstore/lidmap.go index a37a69989..ac95200a8 100644 --- a/store/sqlstore/lidmap.go +++ b/store/sqlstore/lidmap.go @@ -34,6 +34,7 @@ type CachedLIDMap struct { } var _ store.LIDStore = (*CachedLIDMap)(nil) +var _ store.LIDBatchReverseStore = (*CachedLIDMap)(nil) const maxLIDCacheEntries = 65536 @@ -257,10 +258,16 @@ func (s *CachedLIDMap) GetManyPNsForLIDs(ctx context.Context, lids []types.JID) if lid.Server != types.HiddenUserServer { continue } - if pnUser, ok := s.lidToPNCache[lid.User]; ok && pnUser != "" { - result[lid] = types.JID{User: pnUser, Device: lid.Device, Server: types.DefaultUserServer} - } else if !s.cacheFilled { - missingLIDs = append(missingLIDs, lid.User) + if pnUser, ok := s.lidToPNCache[lid.User]; ok { + if pnUser != "" { + result[lid] = types.JID{User: pnUser, Device: lid.Device, Server: types.DefaultUserServer} + } + continue + } + if !s.cacheFilled { + if _, exists := missingLIDDevices[lid.User]; !exists { + missingLIDs = append(missingLIDs, lid.User) + } missingLIDDevices[lid.User] = append(missingLIDDevices[lid.User], lid) } } @@ -272,6 +279,22 @@ func (s *CachedLIDMap) GetManyPNsForLIDs(ctx context.Context, lids []types.JID) s.lidCacheLock.Lock() defer s.lidCacheLock.Unlock() + queryLIDs := missingLIDs[:0] + for _, lid := range missingLIDs { + if pn, ok := s.lidToPNCache[lid]; ok { + if pn != "" { + for _, dev := range missingLIDDevices[lid] { + result[dev] = types.JID{User: pn, Device: dev.Device, Server: types.DefaultUserServer} + } + } + } else if !s.cacheFilled { + queryLIDs = append(queryLIDs, lid) + } + } + missingLIDs = queryLIDs + if len(missingLIDs) == 0 { + return result, nil + } var res dbutil.RowIter[store.LIDMapping] if wrapped, ok := wrapPostgresArray(s.db, missingLIDs); ok { @@ -291,7 +314,9 @@ func (s *CachedLIDMap) GetManyPNsForLIDs(ctx context.Context, lids []types.JID) exslices.CastToAny(missingLIDs)..., )) } + found := make(map[string]struct{}, len(missingLIDs)) _, err := s.scanManyLids(res, func(lid, pn string) { + found[lid] = struct{}{} for _, dev := range missingLIDDevices[lid] { pnDev := dev pnDev.Server = types.DefaultUserServer @@ -299,6 +324,13 @@ func (s *CachedLIDMap) GetManyPNsForLIDs(ctx context.Context, lids []types.JID) result[dev] = pnDev } }) + if err == nil { + for _, lid := range missingLIDs { + if _, ok := found[lid]; !ok { + s.cacheMissLocked(s.lidToPNCache, s.pnToLIDCache, lid) + } + } + } return result, err } diff --git a/store/sqlstore/lidmap_test.go b/store/sqlstore/lidmap_test.go index 94b01124c..efff9e4eb 100644 --- a/store/sqlstore/lidmap_test.go +++ b/store/sqlstore/lidmap_test.go @@ -2,12 +2,45 @@ package sqlstore import ( "context" + "database/sql" + "database/sql/driver" + "errors" "fmt" + "io" "testing" "go.mau.fi/whatsmeow/types" ) +type emptyLIDMapDB struct{ queries int } + +type emptyLIDMapConnector struct{ state *emptyLIDMapDB } + +func (connector *emptyLIDMapConnector) Connect(context.Context) (driver.Conn, error) { + return &emptyLIDMapConn{state: connector.state}, nil +} + +func (*emptyLIDMapConnector) Driver() driver.Driver { return pnMigrationTestDriver{} } + +type emptyLIDMapConn struct{ state *emptyLIDMapDB } + +func (*emptyLIDMapConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} + +func (*emptyLIDMapConn) Close() error { return nil } +func (*emptyLIDMapConn) Begin() (driver.Tx, error) { return nil, errors.New("unexpected transaction") } +func (conn *emptyLIDMapConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + conn.state.queries++ + return emptyLIDMapRows{}, nil +} + +type emptyLIDMapRows struct{} + +func (emptyLIDMapRows) Columns() []string { return []string{"lid", "pn"} } +func (emptyLIDMapRows) Close() error { return nil } +func (emptyLIDMapRows) Next([]driver.Value) error { return io.EOF } + func TestLIDCacheIsBounded(t *testing.T) { cache := NewCachedLIDMap(nil) for i := 0; i <= maxLIDCacheEntries; i++ { @@ -51,3 +84,24 @@ func TestGetManyPNsForLIDsUsesReverseCache(t *testing.T) { } } } + +func TestGetManyPNsForLIDsCachesMissingMappings(t *testing.T) { + state := &emptyLIDMapDB{} + db := sql.OpenDB(&emptyLIDMapConnector{state: state}) + t.Cleanup(func() { _ = db.Close() }) + cache := NewWithDB(db, "sqlite3", nil).LIDMap + lid := types.NewJID("100000000000001", types.HiddenUserServer) + + for range 2 { + got, err := cache.GetManyPNsForLIDs(context.Background(), []types.JID{lid}) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("missing mapping returned %#v", got) + } + } + if state.queries != 1 { + t.Fatalf("missing mapping queried %d times, want 1", state.queries) + } +} diff --git a/store/store.go b/store/store.go index 893ee2ed4..6bdc39931 100644 --- a/store/store.go +++ b/store/store.go @@ -208,6 +208,9 @@ type LIDStore interface { GetPNForLID(ctx context.Context, lid types.JID) (types.JID, error) GetLIDForPN(ctx context.Context, pn types.JID) (types.JID, error) GetManyLIDsForPNs(ctx context.Context, pns []types.JID) (map[types.JID]types.JID, error) +} + +type LIDBatchReverseStore interface { GetManyPNsForLIDs(ctx context.Context, lids []types.JID) (map[types.JID]types.JID, error) } diff --git a/store/store_test.go b/store/store_test.go index 5999d68c6..d269402c0 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -15,6 +15,25 @@ type blockingDeviceContainer struct { deleteStarted chan struct{} } +type legacyLIDStore struct{} + +func (*legacyLIDStore) PutManyLIDMappings(context.Context, []LIDMapping) error { return nil } +func (*legacyLIDStore) PutLIDMapping(context.Context, types.JID, types.JID) error { + return nil +} +func (*legacyLIDStore) GetPNForLID(context.Context, types.JID) (types.JID, error) { + return types.EmptyJID, nil +} +func (*legacyLIDStore) GetLIDForPN(context.Context, types.JID) (types.JID, error) { + return types.EmptyJID, nil +} +func (*legacyLIDStore) GetManyLIDsForPNs(context.Context, []types.JID) (map[types.JID]types.JID, error) { + return nil, nil +} + +var _ LIDStore = (*legacyLIDStore)(nil) +var _ LIDBatchReverseStore = (*NoopStore)(nil) + func (c *blockingDeviceContainer) PutDevice(_ context.Context, device *Device) error { close(c.putStarted) <-c.allowPut From 8029e1fd308c72d037d1414ed17ec76555c02712 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 12:45:19 +0300 Subject: [PATCH 110/163] fix(sqlstore): chunk reverse LID lookups --- store/sqlstore/lidmap.go | 62 ++++++++++++++++++++--------------- store/sqlstore/lidmap_test.go | 29 ++++++++++++++-- 2 files changed, 62 insertions(+), 29 deletions(-) diff --git a/store/sqlstore/lidmap.go b/store/sqlstore/lidmap.go index ac95200a8..894843944 100644 --- a/store/sqlstore/lidmap.go +++ b/store/sqlstore/lidmap.go @@ -296,44 +296,52 @@ func (s *CachedLIDMap) GetManyPNsForLIDs(ctx context.Context, lids []types.JID) return result, nil } - var res dbutil.RowIter[store.LIDMapping] + found := make(map[string]struct{}, len(missingLIDs)) + scanResults := func(res dbutil.RowIter[store.LIDMapping]) error { + _, err := s.scanManyLids(res, func(lid, pn string) { + found[lid] = struct{}{} + for _, dev := range missingLIDDevices[lid] { + pnDev := dev + pnDev.Server = types.DefaultUserServer + pnDev.User = pn + result[dev] = pnDev + } + }) + return err + } if wrapped, ok := wrapPostgresArray(s.db, missingLIDs); ok { - res = convertLIDRow.NewRowIter(s.db.Query( + if err := scanResults(convertLIDRow.NewRowIter(s.db.Query( ctx, `SELECT lid, pn FROM whatsmeow_lid_map WHERE lid = ANY($1)`, wrapped, - )) + ))); err != nil { + return result, err + } } else { - placeholders := make([]string, len(missingLIDs)) - for i := range missingLIDs { - placeholders[i] = fmt.Sprintf("$%d", i+1) + for lids := range slices.Chunk(missingLIDs, lidQueryBatchSize) { + placeholders := make([]string, len(lids)) + for i := range lids { + placeholders[i] = fmt.Sprintf("$%d", i+1) + } + if err := scanResults(convertLIDRow.NewRowIter(s.db.Query( + ctx, + fmt.Sprintf(`SELECT lid, pn FROM whatsmeow_lid_map WHERE lid IN (%s)`, strings.Join(placeholders, ",")), + exslices.CastToAny(lids)..., + ))); err != nil { + return result, err + } } - res = convertLIDRow.NewRowIter(s.db.Query( - ctx, - fmt.Sprintf(`SELECT lid, pn FROM whatsmeow_lid_map WHERE lid IN (%s)`, strings.Join(placeholders, ",")), - exslices.CastToAny(missingLIDs)..., - )) } - found := make(map[string]struct{}, len(missingLIDs)) - _, err := s.scanManyLids(res, func(lid, pn string) { - found[lid] = struct{}{} - for _, dev := range missingLIDDevices[lid] { - pnDev := dev - pnDev.Server = types.DefaultUserServer - pnDev.User = pn - result[dev] = pnDev - } - }) - if err == nil { - for _, lid := range missingLIDs { - if _, ok := found[lid]; !ok { - s.cacheMissLocked(s.lidToPNCache, s.pnToLIDCache, lid) - } + for _, lid := range missingLIDs { + if _, ok := found[lid]; !ok { + s.cacheMissLocked(s.lidToPNCache, s.pnToLIDCache, lid) } } - return result, err + return result, nil } +const lidQueryBatchSize = 300 + func (s *CachedLIDMap) PutLIDMapping(ctx context.Context, lid, pn types.JID) error { if lid.Server != types.HiddenUserServer || pn.Server != types.DefaultUserServer { return fmt.Errorf("invalid PutLIDMapping call %s/%s", lid, pn) diff --git a/store/sqlstore/lidmap_test.go b/store/sqlstore/lidmap_test.go index efff9e4eb..9c0a7e57b 100644 --- a/store/sqlstore/lidmap_test.go +++ b/store/sqlstore/lidmap_test.go @@ -12,7 +12,10 @@ import ( "go.mau.fi/whatsmeow/types" ) -type emptyLIDMapDB struct{ queries int } +type emptyLIDMapDB struct { + queries int + maxArgs int +} type emptyLIDMapConnector struct{ state *emptyLIDMapDB } @@ -30,8 +33,9 @@ func (*emptyLIDMapConn) Prepare(string) (driver.Stmt, error) { func (*emptyLIDMapConn) Close() error { return nil } func (*emptyLIDMapConn) Begin() (driver.Tx, error) { return nil, errors.New("unexpected transaction") } -func (conn *emptyLIDMapConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { +func (conn *emptyLIDMapConn) QueryContext(_ context.Context, _ string, args []driver.NamedValue) (driver.Rows, error) { conn.state.queries++ + conn.state.maxArgs = max(conn.state.maxArgs, len(args)) return emptyLIDMapRows{}, nil } @@ -105,3 +109,24 @@ func TestGetManyPNsForLIDsCachesMissingMappings(t *testing.T) { t.Fatalf("missing mapping queried %d times, want 1", state.queries) } } + +func TestGetManyPNsForLIDsChunksFallbackQueries(t *testing.T) { + state := &emptyLIDMapDB{} + db := sql.OpenDB(&emptyLIDMapConnector{state: state}) + t.Cleanup(func() { _ = db.Close() }) + cache := NewWithDB(db, "sqlite3", nil).LIDMap + lids := make([]types.JID, lidQueryBatchSize*2+1) + for i := range lids { + lids[i] = types.NewJID(fmt.Sprintf("%d", 100000000000000+i), types.HiddenUserServer) + } + + if _, err := cache.GetManyPNsForLIDs(context.Background(), lids); err != nil { + t.Fatal(err) + } + if state.queries != 3 { + t.Fatalf("fallback issued %d queries, want 3", state.queries) + } + if state.maxArgs > lidQueryBatchSize { + t.Fatalf("fallback query used %d arguments, limit %d", state.maxArgs, lidQueryBatchSize) + } +} From f7e221d6e43c5536a6cc3366bdc7489b675a87f6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 17:56:03 +0300 Subject: [PATCH 111/163] Validate phone number consent with Barback --- benchmark/barback/README.md | 5 + benchmark/barback/cmd/bench/main.go | 261 +++++++++++++++-------- benchmark/barback/cmd/bench/main_test.go | 30 +++ benchmark/barback/compose.dm.yaml | 2 + benchmark/barback/compose.yaml | 3 + 5 files changed, 217 insertions(+), 84 deletions(-) diff --git a/benchmark/barback/README.md b/benchmark/barback/README.md index b757f7d31..620cf423e 100644 --- a/benchmark/barback/README.md +++ b/benchmark/barback/README.md @@ -78,3 +78,8 @@ Set `MEM_PROFILE_PATH=/results/run.heap.pb.gz` to capture a full-rate Go allocat Change only one workload dimension at a time. Recommended group sizes are 32, 128, 512, and 1024. Keep the Barback revision, mode, sender count, rate, total, history size, container limits, host power state, and Docker version fixed across a baseline/candidate pair. Set `BENCH_BUSINESS_SMOKE=true` to validate live catalog, product, collection, product-list, and order reads through the paired HyperMeow connection before accepting the benchmark result. The fixtures are generated by Barback and never contact a WhatsApp account. + +Set `BENCH_PHONE_CONSENT_SMOKE=true BARBACK_CAPTURE_MESSAGES=10` to send a +request-phone-number consent message through Signal and verify its decrypted +protobuf in Barback's bounded capture buffer. This uses only the synthetic +browser and fake phone identities. diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index dc641c87b..a460af12d 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "crypto/x509" "database/sql" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -26,27 +27,30 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" + "google.golang.org/protobuf/proto" ) var revision = "working-tree" type config struct { - DatabaseURL string - BarbackURL string - BarbackWS string - TLSCAPath string - TLSServerName string - OutputPath string - MemProfilePath string - Variant string - BusinessSmoke bool - Total int64 - Timeout time.Duration - Workload workloadConfig + DatabaseURL string + BarbackURL string + BarbackWS string + TLSCAPath string + TLSServerName string + OutputPath string + MemProfilePath string + Variant string + BusinessSmoke bool + PhoneConsentSmoke bool + Total int64 + Timeout time.Duration + Workload workloadConfig } type workloadConfig struct { @@ -98,34 +102,35 @@ type runtimeStats struct { } type result struct { - Variant string `json:"variant"` - Revision string `json:"revision"` - StartedAt time.Time `json:"started_at"` - Completed bool `json:"completed"` - Error string `json:"error,omitempty"` - TargetMessages int64 `json:"target_messages"` - Workload workloadConfig `json:"workload"` - MessagesReceived int64 `json:"messages_received"` - MessagesSent int64 `json:"messages_sent"` - SendFailures int64 `json:"send_failures"` - FailureReasons map[string]int64 `json:"failure_reasons,omitempty"` - QueueOverflows int64 `json:"queue_overflows"` - HistorySyncs int64 `json:"history_syncs"` - HistoryConversations int64 `json:"history_conversations"` - HistoryMessages int64 `json:"history_messages"` - DurationMS float64 `json:"duration_ms"` - ThroughputPerSec float64 `json:"throughput_per_sec"` - SendLatency latencyStats `json:"send_latency"` - Database databaseStats `json:"database"` - Runtime runtimeStats `json:"runtime"` - WorkloadRuntime workloadRuntimeStats `json:"workload_runtime"` - SessionRuntime workloadRuntimeStats `json:"session_runtime"` - Resources resourceStats `json:"resources"` - MessageTypes map[string]int64 `json:"message_types"` - MediaUploads int64 `json:"media_uploads"` - MediaUploadBytes int64 `json:"media_upload_bytes"` - MediaUploadLatency latencyStats `json:"media_upload_latency"` - BusinessAppValidated bool `json:"business_app_validated"` + Variant string `json:"variant"` + Revision string `json:"revision"` + StartedAt time.Time `json:"started_at"` + Completed bool `json:"completed"` + Error string `json:"error,omitempty"` + TargetMessages int64 `json:"target_messages"` + Workload workloadConfig `json:"workload"` + MessagesReceived int64 `json:"messages_received"` + MessagesSent int64 `json:"messages_sent"` + SendFailures int64 `json:"send_failures"` + FailureReasons map[string]int64 `json:"failure_reasons,omitempty"` + QueueOverflows int64 `json:"queue_overflows"` + HistorySyncs int64 `json:"history_syncs"` + HistoryConversations int64 `json:"history_conversations"` + HistoryMessages int64 `json:"history_messages"` + DurationMS float64 `json:"duration_ms"` + ThroughputPerSec float64 `json:"throughput_per_sec"` + SendLatency latencyStats `json:"send_latency"` + Database databaseStats `json:"database"` + Runtime runtimeStats `json:"runtime"` + WorkloadRuntime workloadRuntimeStats `json:"workload_runtime"` + SessionRuntime workloadRuntimeStats `json:"session_runtime"` + Resources resourceStats `json:"resources"` + MessageTypes map[string]int64 `json:"message_types"` + MediaUploads int64 `json:"media_uploads"` + MediaUploadBytes int64 `json:"media_upload_bytes"` + MediaUploadLatency latencyStats `json:"media_upload_latency"` + BusinessAppValidated bool `json:"business_app_validated"` + PhoneConsentValidated bool `json:"phone_consent_validated"` } type runner struct { @@ -133,17 +138,19 @@ type runner struct { db *sql.DB httpClient *http.Client - received atomic.Int64 - sent atomic.Int64 - failed atomic.Int64 - overflows atomic.Int64 - historySyncs atomic.Int64 - historyConvs atomic.Int64 - historyMessages atomic.Int64 - messageSequence atomic.Int64 - mediaUploads atomic.Int64 - mediaBytes atomic.Int64 - businessValid atomic.Bool + received atomic.Int64 + sent atomic.Int64 + failed atomic.Int64 + overflows atomic.Int64 + historySyncs atomic.Int64 + historyConvs atomic.Int64 + historyMessages atomic.Int64 + messageSequence atomic.Int64 + mediaUploads atomic.Int64 + mediaBytes atomic.Int64 + businessValid atomic.Bool + phoneConsentValid atomic.Bool + phoneConsentOnce sync.Once startOnce sync.Once doneOnce sync.Once @@ -266,18 +273,23 @@ func loadConfig() (config, error) { if err != nil { return config{}, fmt.Errorf("invalid BENCH_BUSINESS_SMOKE") } + phoneConsentSmoke, err := strconv.ParseBool(env("BENCH_PHONE_CONSENT_SMOKE", "false")) + if err != nil { + return config{}, fmt.Errorf("invalid BENCH_PHONE_CONSENT_SMOKE") + } return config{ - DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@postgres:5432/hypermeow?sslmode=disable"), - BarbackURL: env("BARBACK_URL", "http://barback:8080"), - BarbackWS: env("BARBACK_WS", "ws://barback:8080/ws/chat"), - TLSCAPath: os.Getenv("BARBACK_TLS_CA"), - TLSServerName: env("BARBACK_TLS_SERVER_NAME", "0.0.0.0"), - OutputPath: env("RESULT_PATH", "/results/result.json"), - MemProfilePath: os.Getenv("MEM_PROFILE_PATH"), - Variant: env("BENCH_VARIANT", "candidate"), - BusinessSmoke: businessSmoke, - Total: total, - Timeout: timeout, + DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@postgres:5432/hypermeow?sslmode=disable"), + BarbackURL: env("BARBACK_URL", "http://barback:8080"), + BarbackWS: env("BARBACK_WS", "ws://barback:8080/ws/chat"), + TLSCAPath: os.Getenv("BARBACK_TLS_CA"), + TLSServerName: env("BARBACK_TLS_SERVER_NAME", "0.0.0.0"), + OutputPath: env("RESULT_PATH", "/results/result.json"), + MemProfilePath: os.Getenv("MEM_PROFILE_PATH"), + Variant: env("BENCH_VARIANT", "candidate"), + BusinessSmoke: businessSmoke, + PhoneConsentSmoke: phoneConsentSmoke, + Total: total, + Timeout: timeout, Workload: workloadConfig{ Scenario: env("BENCH_SCENARIO", "custom"), Mode: mode, @@ -546,6 +558,22 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs case <-ctx.Done(): return case msg := <-jobs: + var consentErr error + if r.cfg.PhoneConsentSmoke { + r.phoneConsentOnce.Do(func() { + consentErr = r.validatePhoneNumberConsent(ctx, client, phoneConsentTarget(msg)) + if consentErr == nil { + r.phoneConsentValid.Store(true) + } + }) + } + if consentErr != nil { + r.failed.Add(1) + r.recordFailure(consentErr) + fmt.Fprintf(os.Stderr, "validate phone number consent: %v\n", consentErr) + r.checkDone() + continue + } sequence := r.messageSequence.Add(1) - 1 outgoing, category, mediaBytes, uploadDuration, err := buildWorkloadMessage(ctx, client, string(msg.Info.ID), sequence, r.cfg.Workload.MessageProfile) if uploadDuration > 0 { @@ -580,6 +608,70 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs } } +func phoneConsentTarget(message *events.Message) types.JID { + if message.Info.SenderAlt.Server == types.HiddenUserServer { + return message.Info.SenderAlt.ToNonAD() + } + if message.Info.Sender.Server == types.HiddenUserServer { + return message.Info.Sender.ToNonAD() + } + return message.Info.Chat.ToNonAD() +} + +type capturedMessage struct { + PlaintextBase64 string `json:"plaintext_base64"` +} + +func containsRequestPhoneNumberCapture(captures []capturedMessage) bool { + for _, capture := range captures { + plaintext, err := base64.StdEncoding.DecodeString(capture.PlaintextBase64) + if err != nil { + continue + } + var message waE2E.Message + if proto.Unmarshal(plaintext, &message) == nil && message.GetRequestPhoneNumberMessage() != nil { + return true + } + } + return false +} + +func (r *runner) validatePhoneNumberConsent(ctx context.Context, client *whatsmeow.Client, chat types.JID) error { + if chat.Server != types.HiddenUserServer { + return fmt.Errorf("synthetic phone consent target is not a LID") + } + if _, err := client.SendMessage(ctx, chat, whatsmeow.BuildRequestPhoneNumberMessage(nil)); err != nil { + return fmt.Errorf("send request phone number message: %w", err) + } + + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.cfg.BarbackURL+"/admin/mock-phone/captured-messages", nil) + if err != nil { + return err + } + resp, err := r.httpClient.Do(req) + if err == nil { + var captures []capturedMessage + decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&captures) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK && decodeErr == nil && containsRequestPhoneNumberCapture(captures) { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return fmt.Errorf("Barback did not capture request phone number message") + case <-ticker.C: + } + } +} + func (r *runner) checkDone() { if r.sent.Load()+r.failed.Load() >= r.cfg.Total { r.doneOnce.Do(func() { @@ -605,28 +697,29 @@ func (r *runner) snapshot(completed bool) result { } runtimeNow := runtimeSnapshot() res := result{ - Variant: r.cfg.Variant, - Revision: revision, - StartedAt: startedTime, - Completed: completed && r.failed.Load() == 0 && r.sent.Load() == r.cfg.Total, - TargetMessages: r.cfg.Total, - Workload: r.cfg.Workload, - MessagesReceived: r.received.Load(), - MessagesSent: r.sent.Load(), - SendFailures: r.failed.Load(), - FailureReasons: r.failureReasonSnapshot(), - QueueOverflows: r.overflows.Load(), - HistorySyncs: r.historySyncs.Load(), - HistoryConversations: r.historyConvs.Load(), - HistoryMessages: r.historyMessages.Load(), - DurationMS: float64(duration) / float64(time.Millisecond), - SendLatency: r.latencySnapshot(), - Runtime: runtimeNow, - MessageTypes: r.messageTypeSnapshot(), - MediaUploads: r.mediaUploads.Load(), - MediaUploadBytes: r.mediaBytes.Load(), - MediaUploadLatency: r.uploadLatencySnapshot(), - BusinessAppValidated: r.businessValid.Load(), + Variant: r.cfg.Variant, + Revision: revision, + StartedAt: startedTime, + Completed: completed && r.failed.Load() == 0 && r.sent.Load() == r.cfg.Total, + TargetMessages: r.cfg.Total, + Workload: r.cfg.Workload, + MessagesReceived: r.received.Load(), + MessagesSent: r.sent.Load(), + SendFailures: r.failed.Load(), + FailureReasons: r.failureReasonSnapshot(), + QueueOverflows: r.overflows.Load(), + HistorySyncs: r.historySyncs.Load(), + HistoryConversations: r.historyConvs.Load(), + HistoryMessages: r.historyMessages.Load(), + DurationMS: float64(duration) / float64(time.Millisecond), + SendLatency: r.latencySnapshot(), + Runtime: runtimeNow, + MessageTypes: r.messageTypeSnapshot(), + MediaUploads: r.mediaUploads.Load(), + MediaUploadBytes: r.mediaBytes.Load(), + MediaUploadLatency: r.uploadLatencySnapshot(), + BusinessAppValidated: r.businessValid.Load(), + PhoneConsentValidated: r.phoneConsentValid.Load(), } r.metricsMu.Lock() if r.sessionStarted { diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 05424f7f5..620be0585 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -1,11 +1,15 @@ package main import ( + "encoding/base64" "strconv" "testing" "time" + "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" ) func TestLoadConfigWorkload(t *testing.T) { @@ -21,6 +25,7 @@ func TestLoadConfigWorkload(t *testing.T) { t.Setenv("HISTORY_CONVERSATIONS", "10") t.Setenv("HISTORY_MESSAGES", "5") t.Setenv("BENCH_BUSINESS_SMOKE", "1") + t.Setenv("BENCH_PHONE_CONSENT_SMOKE", "1") cfg, err := loadConfig() if err != nil { @@ -38,6 +43,9 @@ func TestLoadConfigWorkload(t *testing.T) { if !cfg.BusinessSmoke { t.Fatal("business smoke validation was not enabled") } + if !cfg.PhoneConsentSmoke { + t.Fatal("phone consent smoke validation was not enabled") + } } func TestLoadConfigRejectsInvalidWorkload(t *testing.T) { @@ -112,3 +120,25 @@ func TestJobShardDistributesChats(t *testing.T) { t.Fatalf("chat sharding is too concentrated: %d shards", len(seen)) } } + +func TestContainsRequestPhoneNumberCapture(t *testing.T) { + payload, err := proto.Marshal(whatsmeow.BuildRequestPhoneNumberMessage(nil)) + if err != nil { + t.Fatal(err) + } + captures := []capturedMessage{{PlaintextBase64: base64.StdEncoding.EncodeToString(payload)}} + if !containsRequestPhoneNumberCapture(captures) { + t.Fatal("request phone number message was not detected") + } +} + +func TestPhoneConsentTargetPrefersSenderLID(t *testing.T) { + message := &events.Message{Info: types.MessageInfo{MessageSource: types.MessageSource{ + Chat: types.NewJID("15550001111", types.DefaultUserServer), + Sender: types.NewJID("15550001111", types.DefaultUserServer), + SenderAlt: types.NewJID("100000011111111", types.HiddenUserServer), + }}} + if got := phoneConsentTarget(message); got.String() != "100000011111111@lid" { + t.Fatalf("target = %s", got) + } +} diff --git a/benchmark/barback/compose.dm.yaml b/benchmark/barback/compose.dm.yaml index 4875a57f2..459ee54c3 100644 --- a/benchmark/barback/compose.dm.yaml +++ b/benchmark/barback/compose.dm.yaml @@ -22,3 +22,5 @@ services: - ${HISTORY_CONVERSATIONS:-100} - --history-sync-messages - ${HISTORY_MESSAGES:-20} + - --capture-messages + - ${BARBACK_CAPTURE_MESSAGES:-0} diff --git a/benchmark/barback/compose.yaml b/benchmark/barback/compose.yaml index 8741dce3c..2256616c9 100644 --- a/benchmark/barback/compose.yaml +++ b/benchmark/barback/compose.yaml @@ -70,6 +70,8 @@ services: - ${HISTORY_CONVERSATIONS:-100} - --history-sync-messages - ${HISTORY_MESSAGES:-20} + - --capture-messages + - ${BARBACK_CAPTURE_MESSAGES:-0} depends_on: cert-init: condition: service_completed_successfully @@ -112,6 +114,7 @@ services: BENCH_VARIANT: ${BENCH_VARIANT:-candidate} BENCH_GROUP_SIZE: ${BENCH_GROUP_SIZE:-128} BENCH_BUSINESS_SMOKE: ${BENCH_BUSINESS_SMOKE:-false} + BENCH_PHONE_CONSENT_SMOKE: ${BENCH_PHONE_CONSENT_SMOKE:-false} BENCH_MODE: ${BENCH_MODE:-group} BENCH_MESSAGE_PROFILE: ${BENCH_MESSAGE_PROFILE:-text} BENCH_RATE: ${BENCH_RATE:-50} From 5725560b7928a68fb9733078cd92682815ae47f2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 00:43:48 +0300 Subject: [PATCH 112/163] build: format Barback benchmark imports --- benchmark/barback/cmd/bench/main.go | 3 ++- benchmark/barback/cmd/bench/main_test.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index a460af12d..2a79df208 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -26,13 +26,14 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" + "google.golang.org/protobuf/proto" + "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" - "google.golang.org/protobuf/proto" ) var revision = "working-tree" diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 620be0585..63b51cecf 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -6,10 +6,11 @@ import ( "testing" "time" + "google.golang.org/protobuf/proto" + "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" - "google.golang.org/protobuf/proto" ) func TestLoadConfigWorkload(t *testing.T) { From 664e324c289ac933b1bc88663f555d71a81f566e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:19:25 +0300 Subject: [PATCH 113/163] test: validate both phone consent messages --- benchmark/barback/cmd/bench/main.go | 18 ++++++++++++------ benchmark/barback/cmd/bench/main_test.go | 18 +++++++++++++----- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 2a79df208..9b3847f74 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -623,18 +623,21 @@ type capturedMessage struct { PlaintextBase64 string `json:"plaintext_base64"` } -func containsRequestPhoneNumberCapture(captures []capturedMessage) bool { +func containsPhoneNumberConsentCaptures(captures []capturedMessage) bool { + var requestFound, shareFound bool for _, capture := range captures { plaintext, err := base64.StdEncoding.DecodeString(capture.PlaintextBase64) if err != nil { continue } var message waE2E.Message - if proto.Unmarshal(plaintext, &message) == nil && message.GetRequestPhoneNumberMessage() != nil { - return true + if proto.Unmarshal(plaintext, &message) != nil { + continue } + requestFound = requestFound || message.GetRequestPhoneNumberMessage() != nil + shareFound = shareFound || message.GetProtocolMessage().GetType() == waE2E.ProtocolMessage_SHARE_PHONE_NUMBER } - return false + return requestFound && shareFound } func (r *runner) validatePhoneNumberConsent(ctx context.Context, client *whatsmeow.Client, chat types.JID) error { @@ -644,6 +647,9 @@ func (r *runner) validatePhoneNumberConsent(ctx context.Context, client *whatsme if _, err := client.SendMessage(ctx, chat, whatsmeow.BuildRequestPhoneNumberMessage(nil)); err != nil { return fmt.Errorf("send request phone number message: %w", err) } + if _, err := client.SendMessage(ctx, chat, whatsmeow.BuildSharePhoneNumberMessage()); err != nil { + return fmt.Errorf("send share phone number message: %w", err) + } deadline := time.NewTimer(5 * time.Second) defer deadline.Stop() @@ -659,7 +665,7 @@ func (r *runner) validatePhoneNumberConsent(ctx context.Context, client *whatsme var captures []capturedMessage decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&captures) _ = resp.Body.Close() - if resp.StatusCode == http.StatusOK && decodeErr == nil && containsRequestPhoneNumberCapture(captures) { + if resp.StatusCode == http.StatusOK && decodeErr == nil && containsPhoneNumberConsentCaptures(captures) { return nil } } @@ -667,7 +673,7 @@ func (r *runner) validatePhoneNumberConsent(ctx context.Context, client *whatsme case <-ctx.Done(): return ctx.Err() case <-deadline.C: - return fmt.Errorf("Barback did not capture request phone number message") + return fmt.Errorf("Barback did not capture request and share phone number messages") case <-ticker.C: } } diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 63b51cecf..f60db8964 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -122,14 +122,22 @@ func TestJobShardDistributesChats(t *testing.T) { } } -func TestContainsRequestPhoneNumberCapture(t *testing.T) { - payload, err := proto.Marshal(whatsmeow.BuildRequestPhoneNumberMessage(nil)) +func TestContainsPhoneNumberConsentCaptures(t *testing.T) { + requestPayload, err := proto.Marshal(whatsmeow.BuildRequestPhoneNumberMessage(nil)) if err != nil { t.Fatal(err) } - captures := []capturedMessage{{PlaintextBase64: base64.StdEncoding.EncodeToString(payload)}} - if !containsRequestPhoneNumberCapture(captures) { - t.Fatal("request phone number message was not detected") + sharePayload, err := proto.Marshal(whatsmeow.BuildSharePhoneNumberMessage()) + if err != nil { + t.Fatal(err) + } + captures := []capturedMessage{{PlaintextBase64: base64.StdEncoding.EncodeToString(requestPayload)}} + if containsPhoneNumberConsentCaptures(captures) { + t.Fatal("request-only capture passed phone consent validation") + } + captures = append(captures, capturedMessage{PlaintextBase64: base64.StdEncoding.EncodeToString(sharePayload)}) + if !containsPhoneNumberConsentCaptures(captures) { + t.Fatal("request and share messages were not detected") } } From 2d29839df47d36e5ad6f2499f97e0b4a8e37eab8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:38:01 +0300 Subject: [PATCH 114/163] test: fail phone consent smoke runs --- benchmark/barback/cmd/bench/main.go | 21 +++++++++++++++++++-- benchmark/barback/cmd/bench/main_test.go | 15 +++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 9b3847f74..f64d45ef8 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -158,6 +158,7 @@ type runner struct { startedAt atomic.Int64 finishedAt atomic.Int64 done chan struct{} + fatalErr chan error jobs []chan *events.Message latencyMu sync.Mutex @@ -196,6 +197,7 @@ func main() { r := &runner{ cfg: cfg, done: make(chan struct{}), + fatalErr: make(chan error, 1), jobs: make([]chan *events.Message, workerCount), latencies: make([]float64, 0, cfg.Total), messageTypes: make(map[string]int64), @@ -418,6 +420,11 @@ func (r *runner) run() (result, error) { select { case <-r.done: + select { + case err = <-r.fatalErr: + return r.snapshot(false), err + default: + } time.Sleep(2 * time.Second) res := r.snapshot(true) return res, nil @@ -572,8 +579,8 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs r.failed.Add(1) r.recordFailure(consentErr) fmt.Fprintf(os.Stderr, "validate phone number consent: %v\n", consentErr) - r.checkDone() - continue + r.reportFatal(fmt.Errorf("validate phone number consent: %w", consentErr)) + return } sequence := r.messageSequence.Add(1) - 1 outgoing, category, mediaBytes, uploadDuration, err := buildWorkloadMessage(ctx, client, string(msg.Info.ID), sequence, r.cfg.Workload.MessageProfile) @@ -609,6 +616,16 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs } } +func (r *runner) reportFatal(err error) { + if err == nil || r.fatalErr == nil { + return + } + select { + case r.fatalErr <- err: + default: + } +} + func phoneConsentTarget(message *events.Message) types.JID { if message.Info.SenderAlt.Server == types.HiddenUserServer { return message.Info.SenderAlt.ToNonAD() diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index f60db8964..e0e43b54a 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/base64" + "errors" "strconv" "testing" "time" @@ -78,6 +79,20 @@ func TestLoadConfigRejectsInvalidMode(t *testing.T) { } } +func TestRunnerReportsFatalSmokeFailure(t *testing.T) { + r := &runner{fatalErr: make(chan error, 1)} + sentinel := errors.New("phone consent failed") + r.reportFatal(sentinel) + select { + case err := <-r.fatalErr: + if !errors.Is(err, sentinel) { + t.Fatalf("fatal error = %v", err) + } + default: + t.Fatal("fatal smoke failure was not propagated") + } +} + func TestLoadConfigRejectsInvalidMessageProfile(t *testing.T) { t.Setenv("BENCH_MESSAGE_PROFILE", "production") if _, err := loadConfig(); err == nil { From a5b452f1f1306b05461aa04e87b57aedbfb9720d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:52:43 +0300 Subject: [PATCH 115/163] fix: wake benchmark on fatal validation --- benchmark/barback/cmd/bench/main.go | 21 +++++++++++++++------ benchmark/barback/cmd/bench/main_test.go | 13 +++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index f64d45ef8..083e9d16f 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -418,18 +418,27 @@ func (r *runner) run() (result, error) { r.businessValid.Store(true) } + if err = r.waitForRunCompletion(ctx); err != nil { + return r.snapshot(false), err + } + time.Sleep(2 * time.Second) + res := r.snapshot(true) + return res, nil +} + +func (r *runner) waitForRunCompletion(ctx context.Context) error { select { case <-r.done: select { - case err = <-r.fatalErr: - return r.snapshot(false), err + case err := <-r.fatalErr: + return err default: + return nil } - time.Sleep(2 * time.Second) - res := r.snapshot(true) - return res, nil + case err := <-r.fatalErr: + return err case <-ctx.Done(): - return r.snapshot(false), fmt.Errorf("benchmark incomplete: %w", ctx.Err()) + return fmt.Errorf("benchmark incomplete: %w", ctx.Err()) } } diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index e0e43b54a..2720c3440 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/base64" "errors" "strconv" @@ -93,6 +94,18 @@ func TestRunnerReportsFatalSmokeFailure(t *testing.T) { } } +func TestWaitForRunCompletionWakesOnFatalSmokeFailure(t *testing.T) { + r := &runner{done: make(chan struct{}), fatalErr: make(chan error, 1)} + sentinel := errors.New("phone consent failed") + r.reportFatal(sentinel) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := r.waitForRunCompletion(ctx); !errors.Is(err, sentinel) { + t.Fatalf("completion error = %v, want %v", err, sentinel) + } +} + func TestLoadConfigRejectsInvalidMessageProfile(t *testing.T) { t.Setenv("BENCH_MESSAGE_PROFILE", "production") if _, err := loadConfig(); err == nil { From 8861e6f27dbf0708bc4db48f3a95035cb782fde9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:01:18 +0300 Subject: [PATCH 116/163] fix: share phone consent validation failures --- benchmark/barback/cmd/bench/main.go | 27 ++++++++++----- benchmark/barback/cmd/bench/main_test.go | 42 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 083e9d16f..635a5ea39 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -152,6 +152,7 @@ type runner struct { businessValid atomic.Bool phoneConsentValid atomic.Bool phoneConsentOnce sync.Once + phoneConsentErr error startOnce sync.Once doneOnce sync.Once @@ -577,18 +578,11 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs case msg := <-jobs: var consentErr error if r.cfg.PhoneConsentSmoke { - r.phoneConsentOnce.Do(func() { - consentErr = r.validatePhoneNumberConsent(ctx, client, phoneConsentTarget(msg)) - if consentErr == nil { - r.phoneConsentValid.Store(true) - } + consentErr = r.runPhoneConsentSmoke(func() error { + return r.validatePhoneNumberConsent(ctx, client, phoneConsentTarget(msg)) }) } if consentErr != nil { - r.failed.Add(1) - r.recordFailure(consentErr) - fmt.Fprintf(os.Stderr, "validate phone number consent: %v\n", consentErr) - r.reportFatal(fmt.Errorf("validate phone number consent: %w", consentErr)) return } sequence := r.messageSequence.Add(1) - 1 @@ -625,6 +619,21 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs } } +func (r *runner) runPhoneConsentSmoke(validate func() error) error { + r.phoneConsentOnce.Do(func() { + r.phoneConsentErr = validate() + if r.phoneConsentErr == nil { + r.phoneConsentValid.Store(true) + return + } + r.failed.Add(1) + r.recordFailure(r.phoneConsentErr) + fmt.Fprintf(os.Stderr, "validate phone number consent: %v\n", r.phoneConsentErr) + r.reportFatal(fmt.Errorf("validate phone number consent: %w", r.phoneConsentErr)) + }) + return r.phoneConsentErr +} + func (r *runner) reportFatal(err error) { if err == nil || r.fatalErr == nil { return diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 2720c3440..a76ebb2c4 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "errors" "strconv" + "sync" + "sync/atomic" "testing" "time" @@ -106,6 +108,46 @@ func TestWaitForRunCompletionWakesOnFatalSmokeFailure(t *testing.T) { } } +func TestPhoneConsentFailureIsSharedAcrossWorkers(t *testing.T) { + r := &runner{ + fatalErr: make(chan error, 1), + failureReasons: make(map[string]int64), + } + sentinel := errors.New("phone consent failed") + entered := make(chan struct{}) + release := make(chan struct{}) + var calls atomic.Int64 + validate := func() error { + if calls.Add(1) == 1 { + close(entered) + } + <-release + return sentinel + } + + var workers sync.WaitGroup + errs := make(chan error, 2) + for range 2 { + workers.Add(1) + go func() { + defer workers.Done() + errs <- r.runPhoneConsentSmoke(validate) + }() + } + <-entered + close(release) + workers.Wait() + close(errs) + for err := range errs { + if !errors.Is(err, sentinel) { + t.Fatalf("worker error = %v, want %v", err, sentinel) + } + } + if calls.Load() != 1 { + t.Fatalf("validation calls = %d, want 1", calls.Load()) + } +} + func TestLoadConfigRejectsInvalidMessageProfile(t *testing.T) { t.Setenv("BENCH_MESSAGE_PROFILE", "production") if _, err := loadConfig(); err == nil { From 3603a3fc39c50f71e4f0669a52a10cf5f2798a42 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:21:02 +0300 Subject: [PATCH 117/163] fix: keep phone consent baseline compatible --- benchmark/barback/cmd/bench/main.go | 13 +++++++++++-- benchmark/barback/cmd/bench/main_test.go | 5 ++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 635a5ea39..ae5421330 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -675,14 +675,23 @@ func containsPhoneNumberConsentCaptures(captures []capturedMessage) bool { return requestFound && shareFound } +func buildRequestPhoneNumberMessage() *waE2E.Message { + return &waE2E.Message{RequestPhoneNumberMessage: &waE2E.RequestPhoneNumberMessage{}} +} + +func buildSharePhoneNumberMessage() *waE2E.Message { + messageType := waE2E.ProtocolMessage_SHARE_PHONE_NUMBER + return &waE2E.Message{ProtocolMessage: &waE2E.ProtocolMessage{Type: &messageType}} +} + func (r *runner) validatePhoneNumberConsent(ctx context.Context, client *whatsmeow.Client, chat types.JID) error { if chat.Server != types.HiddenUserServer { return fmt.Errorf("synthetic phone consent target is not a LID") } - if _, err := client.SendMessage(ctx, chat, whatsmeow.BuildRequestPhoneNumberMessage(nil)); err != nil { + if _, err := client.SendMessage(ctx, chat, buildRequestPhoneNumberMessage()); err != nil { return fmt.Errorf("send request phone number message: %w", err) } - if _, err := client.SendMessage(ctx, chat, whatsmeow.BuildSharePhoneNumberMessage()); err != nil { + if _, err := client.SendMessage(ctx, chat, buildSharePhoneNumberMessage()); err != nil { return fmt.Errorf("send share phone number message: %w", err) } diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index a76ebb2c4..0def8eff4 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -12,7 +12,6 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" ) @@ -193,11 +192,11 @@ func TestJobShardDistributesChats(t *testing.T) { } func TestContainsPhoneNumberConsentCaptures(t *testing.T) { - requestPayload, err := proto.Marshal(whatsmeow.BuildRequestPhoneNumberMessage(nil)) + requestPayload, err := proto.Marshal(buildRequestPhoneNumberMessage()) if err != nil { t.Fatal(err) } - sharePayload, err := proto.Marshal(whatsmeow.BuildSharePhoneNumberMessage()) + sharePayload, err := proto.Marshal(buildSharePhoneNumberMessage()) if err != nil { t.Fatal(err) } From 3011aadc7e09b044d002c2493207e61d9fba321b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 04:33:13 +0300 Subject: [PATCH 118/163] fix: isolate phone consent benchmark smoke --- benchmark/barback/cmd/bench/main.go | 39 +++++++++++++----------- benchmark/barback/cmd/bench/main_test.go | 35 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index ae5421330..7d227a731 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -38,6 +38,8 @@ import ( var revision = "working-tree" +const phoneConsentSmokeTimeout = 5 * time.Second + type config struct { DatabaseURL string BarbackURL string @@ -481,6 +483,11 @@ func (r *runner) handler(client *whatsmeow.Client) whatsmeow.EventHandler { if event.Message.GetConversation() != "ping" { return } + if !r.beforeBenchmarkMessage(func() error { + return r.validatePhoneNumberConsent(context.Background(), client, phoneConsentTarget(event)) + }) { + return + } r.startOnce.Do(r.startMetrics) r.received.Add(1) jobs := r.jobs[jobShard(event.Info.Chat, len(r.jobs))] @@ -576,15 +583,6 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs case <-ctx.Done(): return case msg := <-jobs: - var consentErr error - if r.cfg.PhoneConsentSmoke { - consentErr = r.runPhoneConsentSmoke(func() error { - return r.validatePhoneNumberConsent(ctx, client, phoneConsentTarget(msg)) - }) - } - if consentErr != nil { - return - } sequence := r.messageSequence.Add(1) - 1 outgoing, category, mediaBytes, uploadDuration, err := buildWorkloadMessage(ctx, client, string(msg.Info.ID), sequence, r.cfg.Workload.MessageProfile) if uploadDuration > 0 { @@ -619,6 +617,13 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs } } +func (r *runner) beforeBenchmarkMessage(validate func() error) bool { + if !r.cfg.PhoneConsentSmoke { + return true + } + return r.runPhoneConsentSmoke(validate) == nil +} + func (r *runner) runPhoneConsentSmoke(validate func() error) error { r.phoneConsentOnce.Do(func() { r.phoneConsentErr = validate() @@ -688,19 +693,19 @@ func (r *runner) validatePhoneNumberConsent(ctx context.Context, client *whatsme if chat.Server != types.HiddenUserServer { return fmt.Errorf("synthetic phone consent target is not a LID") } - if _, err := client.SendMessage(ctx, chat, buildRequestPhoneNumberMessage()); err != nil { + smokeCtx, cancel := context.WithTimeout(ctx, phoneConsentSmokeTimeout) + defer cancel() + if _, err := client.SendMessage(smokeCtx, chat, buildRequestPhoneNumberMessage()); err != nil { return fmt.Errorf("send request phone number message: %w", err) } - if _, err := client.SendMessage(ctx, chat, buildSharePhoneNumberMessage()); err != nil { + if _, err := client.SendMessage(smokeCtx, chat, buildSharePhoneNumberMessage()); err != nil { return fmt.Errorf("send share phone number message: %w", err) } - deadline := time.NewTimer(5 * time.Second) - defer deadline.Stop() ticker := time.NewTicker(50 * time.Millisecond) defer ticker.Stop() for { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.cfg.BarbackURL+"/admin/mock-phone/captured-messages", nil) + req, err := http.NewRequestWithContext(smokeCtx, http.MethodGet, r.cfg.BarbackURL+"/admin/mock-phone/captured-messages", nil) if err != nil { return err } @@ -714,10 +719,8 @@ func (r *runner) validatePhoneNumberConsent(ctx context.Context, client *whatsme } } select { - case <-ctx.Done(): - return ctx.Err() - case <-deadline.C: - return fmt.Errorf("Barback did not capture request and share phone number messages") + case <-smokeCtx.Done(): + return fmt.Errorf("Barback did not capture request and share phone number messages: %w", smokeCtx.Err()) case <-ticker.C: } } diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 0def8eff4..5f4d4aa5e 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -147,6 +147,41 @@ func TestPhoneConsentFailureIsSharedAcrossWorkers(t *testing.T) { } } +func TestPhoneConsentSmokeRunsBeforeMeasuredMetrics(t *testing.T) { + r := &runner{ + cfg: config{PhoneConsentSmoke: true}, + fatalErr: make(chan error, 1), + failureReasons: make(map[string]int64), + } + if !r.beforeBenchmarkMessage(func() error { + if r.startedAt.Load() != 0 { + t.Fatal("workload metrics started before phone consent validation") + } + return nil + }) { + t.Fatal("phone consent validation failed") + } + r.startOnce.Do(r.startMetrics) + t.Cleanup(r.stopMetricsSampler) + if r.startedAt.Load() == 0 { + t.Fatal("workload metrics did not start after phone consent validation") + } +} + +func TestPhoneConsentFailureDoesNotStartMeasuredMetrics(t *testing.T) { + r := &runner{ + cfg: config{PhoneConsentSmoke: true}, + fatalErr: make(chan error, 1), + failureReasons: make(map[string]int64), + } + if r.beforeBenchmarkMessage(func() error { return errors.New("failed") }) { + t.Fatal("failed phone consent validation allowed the workload to start") + } + if r.startedAt.Load() != 0 { + t.Fatal("failed phone consent validation started workload metrics") + } +} + func TestLoadConfigRejectsInvalidMessageProfile(t *testing.T) { t.Setenv("BENCH_MESSAGE_PROFILE", "production") if _, err := loadConfig(); err == nil { From af1eeef4b31293b436ad4187a59f8ba58c7e845c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:12:44 +0300 Subject: [PATCH 119/163] fix(bench): cancel phone consent with run --- benchmark/barback/cmd/bench/main.go | 39 +++++++++++++----------- benchmark/barback/cmd/bench/main_test.go | 27 ++++++++++++++++ 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 7d227a731..0a26a22a3 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -141,20 +141,21 @@ type runner struct { db *sql.DB httpClient *http.Client - received atomic.Int64 - sent atomic.Int64 - failed atomic.Int64 - overflows atomic.Int64 - historySyncs atomic.Int64 - historyConvs atomic.Int64 - historyMessages atomic.Int64 - messageSequence atomic.Int64 - mediaUploads atomic.Int64 - mediaBytes atomic.Int64 - businessValid atomic.Bool - phoneConsentValid atomic.Bool - phoneConsentOnce sync.Once - phoneConsentErr error + received atomic.Int64 + sent atomic.Int64 + failed atomic.Int64 + overflows atomic.Int64 + historySyncs atomic.Int64 + historyConvs atomic.Int64 + historyMessages atomic.Int64 + messageSequence atomic.Int64 + mediaUploads atomic.Int64 + mediaBytes atomic.Int64 + businessValid atomic.Bool + phoneConsentValid atomic.Bool + phoneConsentOnce sync.Once + phoneConsentErr error + phoneConsentValidator func(context.Context, *whatsmeow.Client, types.JID) error startOnce sync.Once doneOnce sync.Once @@ -393,7 +394,7 @@ func (r *runner) run() (result, error) { NoiseCertificateAuthority: &root, } client.EnableAutoReconnect = true - client.AddEventHandler(r.handler(client)) + client.AddEventHandler(r.handler(ctx, client)) workerCtx, stopWorkers := context.WithCancel(ctx) defer stopWorkers() @@ -454,7 +455,7 @@ func (r *runner) stopMetricsSampler() { r.metricsMu.Unlock() } -func (r *runner) handler(client *whatsmeow.Client) whatsmeow.EventHandler { +func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeow.EventHandler { var scanOnce sync.Once return func(evt any) { switch event := evt.(type) { @@ -484,7 +485,11 @@ func (r *runner) handler(client *whatsmeow.Client) whatsmeow.EventHandler { return } if !r.beforeBenchmarkMessage(func() error { - return r.validatePhoneNumberConsent(context.Background(), client, phoneConsentTarget(event)) + validate := r.phoneConsentValidator + if validate == nil { + validate = r.validatePhoneNumberConsent + } + return validate(ctx, client, phoneConsentTarget(event)) }) { return } diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 5f4d4aa5e..d4c7389fa 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -12,6 +12,8 @@ import ( "google.golang.org/protobuf/proto" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" ) @@ -182,6 +184,31 @@ func TestPhoneConsentFailureDoesNotStartMeasuredMetrics(t *testing.T) { } } +func TestHandlerUsesRunContextForPhoneConsent(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r := &runner{ + cfg: config{PhoneConsentSmoke: true}, + fatalErr: make(chan error, 1), + failureReasons: make(map[string]int64), + phoneConsentValidator: func(ctx context.Context, _ *whatsmeow.Client, _ types.JID) error { + return ctx.Err() + }, + } + r.handler(ctx, &whatsmeow.Client{})(&events.Message{ + Info: types.MessageInfo{MessageSource: types.MessageSource{Chat: types.NewJID("100000011111111", types.HiddenUserServer)}}, + Message: &waE2E.Message{Conversation: proto.String("ping")}, + }) + select { + case err := <-r.fatalErr: + if !errors.Is(err, context.Canceled) { + t.Fatalf("phone consent error = %v, want context canceled", err) + } + default: + t.Fatal("canceled run context was not propagated to phone consent validation") + } +} + func TestLoadConfigRejectsInvalidMessageProfile(t *testing.T) { t.Setenv("BENCH_MESSAGE_PROFILE", "production") if _, err := loadConfig(); err == nil { From 28458c13b7f85c59129cf8964e284c0b83645159 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 12:56:19 +0300 Subject: [PATCH 120/163] docs(bench): describe both consent messages --- benchmark/barback/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmark/barback/README.md b/benchmark/barback/README.md index 620cf423e..b1e381769 100644 --- a/benchmark/barback/README.md +++ b/benchmark/barback/README.md @@ -80,6 +80,6 @@ Change only one workload dimension at a time. Recommended group sizes are 32, 12 Set `BENCH_BUSINESS_SMOKE=true` to validate live catalog, product, collection, product-list, and order reads through the paired HyperMeow connection before accepting the benchmark result. The fixtures are generated by Barback and never contact a WhatsApp account. Set `BENCH_PHONE_CONSENT_SMOKE=true BARBACK_CAPTURE_MESSAGES=10` to send a -request-phone-number consent message through Signal and verify its decrypted -protobuf in Barback's bounded capture buffer. This uses only the synthetic -browser and fake phone identities. +request-phone-number message and a share-phone-number protocol message through +Signal, then verify both decrypted protobufs in Barback's bounded capture +buffer. This uses only the synthetic browser and fake phone identities. From 273c2393f610d2b2e7cf47d8bf0008a43f9e3038 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 13:04:26 +0300 Subject: [PATCH 121/163] fix(bench): isolate consent accounting --- benchmark/barback/cmd/bench/main.go | 23 ++++++++++++-- benchmark/barback/cmd/bench/main_test.go | 38 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 0a26a22a3..ebed8b4e0 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -156,6 +156,7 @@ type runner struct { phoneConsentOnce sync.Once phoneConsentErr error phoneConsentValidator func(context.Context, *whatsmeow.Client, types.JID) error + statementStatsReset func(context.Context) error startOnce sync.Once doneOnce sync.Once @@ -469,7 +470,7 @@ func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeo close(r.connected) } }) - if _, err := r.db.ExecContext(context.Background(), "SELECT pg_stat_statements_reset()"); err != nil { + if err := r.resetStatementStats(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "reset statement stats: %v\n", err) } r.startSessionMetrics() @@ -489,7 +490,13 @@ func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeo if validate == nil { validate = r.validatePhoneNumberConsent } - return validate(ctx, client, phoneConsentTarget(event)) + if err := validate(ctx, client, phoneConsentTarget(event)); err != nil { + return err + } + if err := r.resetStatementStats(ctx); err != nil { + return fmt.Errorf("reset workload statement stats: %w", err) + } + return nil }) { return } @@ -507,6 +514,17 @@ func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeo } } +func (r *runner) resetStatementStats(ctx context.Context) error { + if r.statementStatsReset != nil { + return r.statementStatsReset(ctx) + } + if r.db == nil { + return nil + } + _, err := r.db.ExecContext(ctx, "SELECT pg_stat_statements_reset()") + return err +} + func (r *runner) startSessionMetrics() { r.metricsMu.Lock() if !r.sessionStarted { @@ -636,7 +654,6 @@ func (r *runner) runPhoneConsentSmoke(validate func() error) error { r.phoneConsentValid.Store(true) return } - r.failed.Add(1) r.recordFailure(r.phoneConsentErr) fmt.Fprintf(os.Stderr, "validate phone number consent: %v\n", r.phoneConsentErr) r.reportFatal(fmt.Errorf("validate phone number consent: %w", r.phoneConsentErr)) diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index d4c7389fa..56d006115 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -182,6 +182,44 @@ func TestPhoneConsentFailureDoesNotStartMeasuredMetrics(t *testing.T) { if r.startedAt.Load() != 0 { t.Fatal("failed phone consent validation started workload metrics") } + if r.failed.Load() != 0 { + t.Fatalf("phone consent validation changed workload send failures to %d", r.failed.Load()) + } +} + +func TestPhoneConsentResetsDatabaseBeforeMeasuredMetrics(t *testing.T) { + jobs := make(chan *events.Message, 1) + var resetCalls atomic.Int64 + r := &runner{ + cfg: config{PhoneConsentSmoke: true}, + fatalErr: make(chan error, 1), + failureReasons: make(map[string]int64), + jobs: []chan *events.Message{jobs}, + } + r.phoneConsentValidator = func(context.Context, *whatsmeow.Client, types.JID) error { + if resetCalls.Load() != 0 { + t.Fatal("statement stats reset before phone consent validation") + } + return nil + } + r.statementStatsReset = func(context.Context) error { + if r.startedAt.Load() != 0 { + t.Fatal("statement stats reset after workload metrics started") + } + resetCalls.Add(1) + return nil + } + r.handler(context.Background(), &whatsmeow.Client{})(&events.Message{ + Info: types.MessageInfo{MessageSource: types.MessageSource{Chat: types.NewJID("100000011111111", types.HiddenUserServer)}}, + Message: &waE2E.Message{Conversation: proto.String("ping")}, + }) + t.Cleanup(r.stopMetricsSampler) + if resetCalls.Load() != 1 { + t.Fatalf("statement stats reset calls = %d, want 1", resetCalls.Load()) + } + if r.startedAt.Load() == 0 { + t.Fatal("workload metrics did not start after statement stats reset") + } } func TestHandlerUsesRunContextForPhoneConsent(t *testing.T) { From 3aa85c6fa2dca6d0ce205a85f5772b29f6be7d43 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 13:16:52 +0300 Subject: [PATCH 122/163] Preserve preflight database metrics --- benchmark/barback/cmd/bench/main.go | 119 ++++++++++++++++------- benchmark/barback/cmd/bench/main_test.go | 51 ++++++++-- 2 files changed, 131 insertions(+), 39 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index ebed8b4e0..9f27aada4 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -141,22 +141,23 @@ type runner struct { db *sql.DB httpClient *http.Client - received atomic.Int64 - sent atomic.Int64 - failed atomic.Int64 - overflows atomic.Int64 - historySyncs atomic.Int64 - historyConvs atomic.Int64 - historyMessages atomic.Int64 - messageSequence atomic.Int64 - mediaUploads atomic.Int64 - mediaBytes atomic.Int64 - businessValid atomic.Bool - phoneConsentValid atomic.Bool - phoneConsentOnce sync.Once - phoneConsentErr error - phoneConsentValidator func(context.Context, *whatsmeow.Client, types.JID) error - statementStatsReset func(context.Context) error + received atomic.Int64 + sent atomic.Int64 + failed atomic.Int64 + overflows atomic.Int64 + historySyncs atomic.Int64 + historyConvs atomic.Int64 + historyMessages atomic.Int64 + messageSequence atomic.Int64 + mediaUploads atomic.Int64 + mediaBytes atomic.Int64 + businessValid atomic.Bool + phoneConsentValid atomic.Bool + phoneConsentOnce sync.Once + phoneConsentErr error + phoneConsentValidator func(context.Context, *whatsmeow.Client, types.JID) error + statementStatsReset func(context.Context) error + statementStatsSnapshot func() databaseStats startOnce sync.Once doneOnce sync.Once @@ -172,17 +173,18 @@ type runner struct { messageTypes map[string]int64 failureReasons map[string]int64 - metricsMu sync.Mutex - runtimeStart runtimeStats - sessionStart runtimeStats - resourceStart resourceStats - metricsStarted bool - sessionStarted bool - tempStop chan struct{} - tempPeakBytes atomic.Int64 - tempPeakFiles atomic.Int64 - connected chan struct{} - connectedOnce sync.Once + metricsMu sync.Mutex + runtimeStart runtimeStats + sessionStart runtimeStats + resourceStart resourceStats + metricsStarted bool + sessionStarted bool + preflightDatabase databaseStats + tempStop chan struct{} + tempPeakBytes atomic.Int64 + tempPeakFiles atomic.Int64 + connected chan struct{} + connectedOnce sync.Once } func main() { @@ -486,6 +488,7 @@ func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeo return } if !r.beforeBenchmarkMessage(func() error { + preflightDatabase := r.snapshotStatementStats() validate := r.phoneConsentValidator if validate == nil { validate = r.validatePhoneNumberConsent @@ -496,6 +499,9 @@ func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeo if err := r.resetStatementStats(ctx); err != nil { return fmt.Errorf("reset workload statement stats: %w", err) } + r.metricsMu.Lock() + r.preflightDatabase = preflightDatabase + r.metricsMu.Unlock() return nil }) { return @@ -525,6 +531,16 @@ func (r *runner) resetStatementStats(ctx context.Context) error { return err } +func (r *runner) snapshotStatementStats() databaseStats { + if r.statementStatsSnapshot != nil { + return r.statementStatsSnapshot() + } + if r.db == nil { + return databaseStats{} + } + return databaseSnapshot(r.db) +} + func (r *runner) startSessionMetrics() { r.metricsMu.Lock() if !r.sessionStarted { @@ -539,13 +555,14 @@ func (r *runner) startMetrics() { r.runtimeStart = runtimeSnapshot() r.resourceStart = resourceSnapshot() r.metricsStarted = true - r.tempStop = make(chan struct{}) + tempStop := make(chan struct{}) + r.tempStop = tempStop r.metricsMu.Unlock() - go r.sampleTemporaryFiles() + go r.sampleTemporaryFiles(tempStop) r.startedAt.Store(time.Now().UnixNano()) } -func (r *runner) sampleTemporaryFiles() { +func (r *runner) sampleTemporaryFiles(stop <-chan struct{}) { ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() for { @@ -553,7 +570,7 @@ func (r *runner) sampleTemporaryFiles() { updatePeak(&r.tempPeakBytes, bytes) updatePeak(&r.tempPeakFiles, files) select { - case <-r.tempStop: + case <-stop: return case <-ticker.C: } @@ -816,8 +833,11 @@ func (r *runner) snapshot(completed bool) result { if duration > 0 { res.ThroughputPerSec = float64(res.MessagesSent) / duration.Seconds() } - if r.db != nil { - res.Database = databaseSnapshot(r.db) + if r.db != nil || r.statementStatsSnapshot != nil { + r.metricsMu.Lock() + preflightDatabase := r.preflightDatabase + r.metricsMu.Unlock() + res.Database = mergeDatabaseStats(preflightDatabase, r.snapshotStatementStats()) } return res } @@ -921,6 +941,39 @@ func databaseSnapshot(db *sql.DB) databaseStats { return stats } +func mergeDatabaseStats(preflight, workload databaseStats) databaseStats { + workload.Calls += preflight.Calls + workload.TotalExecMS += preflight.TotalExecMS + workload.Rows += preflight.Rows + + queries := make(map[string]queryStat, len(preflight.Queries)+len(workload.Queries)) + for _, item := range append(append([]queryStat(nil), preflight.Queries...), workload.Queries...) { + merged := queries[item.Query] + merged.Query = item.Query + merged.Calls += item.Calls + merged.TotalExecMS += item.TotalExecMS + merged.Rows += item.Rows + queries[item.Query] = merged + } + workload.Queries = workload.Queries[:0] + for _, item := range queries { + workload.Queries = append(workload.Queries, item) + } + sort.Slice(workload.Queries, func(i, j int) bool { + if workload.Queries[i].Calls != workload.Queries[j].Calls { + return workload.Queries[i].Calls > workload.Queries[j].Calls + } + if workload.Queries[i].TotalExecMS != workload.Queries[j].TotalExecMS { + return workload.Queries[i].TotalExecMS > workload.Queries[j].TotalExecMS + } + return workload.Queries[i].Query < workload.Queries[j].Query + }) + if len(workload.Queries) > 30 { + workload.Queries = workload.Queries[:30] + } + return workload +} + func runtimeSnapshot() runtimeStats { var mem runtime.MemStats runtime.ReadMemStats(&mem) diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 56d006115..ba939eea5 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -187,25 +187,50 @@ func TestPhoneConsentFailureDoesNotStartMeasuredMetrics(t *testing.T) { } } -func TestPhoneConsentResetsDatabaseBeforeMeasuredMetrics(t *testing.T) { +func TestPhoneConsentPreservesTriggeringPingDatabaseStats(t *testing.T) { jobs := make(chan *events.Message, 1) var resetCalls atomic.Int64 + phase := 0 r := &runner{ - cfg: config{PhoneConsentSmoke: true}, + cfg: config{PhoneConsentSmoke: true, Total: 1}, fatalErr: make(chan error, 1), failureReasons: make(map[string]int64), jobs: []chan *events.Message{jobs}, } + r.statementStatsSnapshot = func() databaseStats { + switch phase { + case 0: + phase = 1 + return databaseStats{ + Calls: 2, TotalExecMS: 3, Rows: 4, SizeBytes: 100, + Queries: []queryStat{{Query: "SELECT whatsmeow_session", Calls: 2, TotalExecMS: 3, Rows: 4}}, + } + case 3: + phase = 4 + return databaseStats{ + Calls: 5, TotalExecMS: 7, Rows: 11, SizeBytes: 200, + Queries: []queryStat{ + {Query: "SELECT whatsmeow_session", Calls: 1, TotalExecMS: 2, Rows: 3}, + {Query: "UPDATE whatsmeow_device", Calls: 4, TotalExecMS: 5, Rows: 8}, + }, + } + default: + t.Fatalf("statement stats snapshot at phase %d", phase) + return databaseStats{} + } + } r.phoneConsentValidator = func(context.Context, *whatsmeow.Client, types.JID) error { - if resetCalls.Load() != 0 { - t.Fatal("statement stats reset before phone consent validation") + if phase != 1 || resetCalls.Load() != 0 { + t.Fatalf("phone consent validation at phase %d after %d resets", phase, resetCalls.Load()) } + phase = 2 return nil } r.statementStatsReset = func(context.Context) error { - if r.startedAt.Load() != 0 { - t.Fatal("statement stats reset after workload metrics started") + if phase != 2 || r.startedAt.Load() != 0 { + t.Fatalf("statement stats reset at phase %d after workload metrics started", phase) } + phase = 3 resetCalls.Add(1) return nil } @@ -220,6 +245,20 @@ func TestPhoneConsentResetsDatabaseBeforeMeasuredMetrics(t *testing.T) { if r.startedAt.Load() == 0 { t.Fatal("workload metrics did not start after statement stats reset") } + result := r.snapshot(false) + if phase != 4 { + t.Fatalf("final statement stats snapshot left phase at %d", phase) + } + if result.Database.Calls != 7 || result.Database.TotalExecMS != 10 || result.Database.Rows != 15 { + t.Fatalf("database totals omitted triggering ping: %+v", result.Database) + } + if result.Database.SizeBytes != 200 { + t.Fatalf("database size = %d, want final size 200", result.Database.SizeBytes) + } + if len(result.Database.Queries) != 2 || result.Database.Queries[0].Query != "UPDATE whatsmeow_device" || + result.Database.Queries[1].Calls != 3 || result.Database.Queries[1].TotalExecMS != 5 || result.Database.Queries[1].Rows != 7 { + t.Fatalf("database query stats were not merged: %+v", result.Database.Queries) + } } func TestHandlerUsesRunContextForPhoneConsent(t *testing.T) { From 3ae4ca8918b4a4631943d3ea7428bb1ee8774be7 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 13:31:46 +0300 Subject: [PATCH 123/163] Isolate consent smoke database metrics --- benchmark/barback/cmd/bench/main.go | 36 ++++++--- benchmark/barback/cmd/bench/main_test.go | 75 +++++++++++++++++++ .../barback/cmd/bench/phone_consent_sync.go | 9 +++ .../cmd/bench/phone_consent_sync_legacy.go | 7 ++ client.go | 7 +- internals.go | 4 + message.go | 33 ++++++-- message_name_updates_test.go | 66 ++++++++++++++++ 8 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 benchmark/barback/cmd/bench/phone_consent_sync.go create mode 100644 benchmark/barback/cmd/bench/phone_consent_sync_legacy.go create mode 100644 message_name_updates_test.go diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 9f27aada4..6dc96089e 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -180,6 +180,8 @@ type runner struct { metricsStarted bool sessionStarted bool preflightDatabase databaseStats + preflightCaptured bool + postConsentClean bool tempStop chan struct{} tempPeakBytes atomic.Int64 tempPeakFiles atomic.Int64 @@ -397,6 +399,9 @@ func (r *runner) run() (result, error) { NoiseCertificateAuthority: &root, } client.EnableAutoReconnect = true + if r.cfg.PhoneConsentSmoke { + enablePhoneConsentReceiveBarrier(client) + } client.AddEventHandler(r.handler(ctx, client)) workerCtx, stopWorkers := context.WithCancel(ctx) @@ -489,19 +494,25 @@ func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeo } if !r.beforeBenchmarkMessage(func() error { preflightDatabase := r.snapshotStatementStats() + r.metricsMu.Lock() + r.preflightDatabase = preflightDatabase + r.preflightCaptured = true + r.metricsMu.Unlock() validate := r.phoneConsentValidator if validate == nil { validate = r.validatePhoneNumberConsent } - if err := validate(ctx, client, phoneConsentTarget(event)); err != nil { - return err - } - if err := r.resetStatementStats(ctx); err != nil { - return fmt.Errorf("reset workload statement stats: %w", err) - } + validationErr := validate(ctx, client, phoneConsentTarget(event)) + resetErr := r.resetStatementStats(ctx) r.metricsMu.Lock() - r.preflightDatabase = preflightDatabase + r.postConsentClean = resetErr == nil r.metricsMu.Unlock() + if validationErr != nil { + return validationErr + } + if resetErr != nil { + return fmt.Errorf("reset workload statement stats: %w", resetErr) + } return nil }) { return @@ -836,8 +847,14 @@ func (r *runner) snapshot(completed bool) result { if r.db != nil || r.statementStatsSnapshot != nil { r.metricsMu.Lock() preflightDatabase := r.preflightDatabase + preflightCaptured := r.preflightCaptured + postConsentClean := r.postConsentClean r.metricsMu.Unlock() - res.Database = mergeDatabaseStats(preflightDatabase, r.snapshotStatementStats()) + if preflightCaptured && !postConsentClean { + res.Database = preflightDatabase + } else { + res.Database = mergeDatabaseStats(preflightDatabase, r.snapshotStatementStats()) + } } return res } @@ -924,8 +941,7 @@ func databaseSnapshot(db *sql.DB) databaseStats { FROM pg_stat_statements WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) AND query ILIKE '%whatsmeow_%' - ORDER BY calls DESC, total_exec_time DESC - LIMIT 30`) + ORDER BY calls DESC, total_exec_time DESC`) if err != nil { return stats } diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index ba939eea5..8cfd39a9a 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -261,6 +261,53 @@ func TestPhoneConsentPreservesTriggeringPingDatabaseStats(t *testing.T) { } } +func TestPhoneConsentFailureExcludesValidationDatabaseStats(t *testing.T) { + phase := 0 + r := &runner{ + cfg: config{PhoneConsentSmoke: true}, + fatalErr: make(chan error, 1), + failureReasons: make(map[string]int64), + phoneConsentValidator: func(context.Context, *whatsmeow.Client, types.JID) error { + if phase != 1 { + t.Fatalf("validation at phase %d", phase) + } + phase = 2 + return errors.New("capture failed") + }, + } + r.statementStatsSnapshot = func() databaseStats { + switch phase { + case 0: + phase = 1 + return databaseStats{Calls: 3, Queries: []queryStat{{Query: "SELECT whatsmeow_session", Calls: 3}}} + case 3: + phase = 4 + return databaseStats{} + default: + t.Fatalf("statement stats snapshot at phase %d", phase) + return databaseStats{} + } + } + r.statementStatsReset = func(context.Context) error { + if phase != 2 { + t.Fatalf("reset at phase %d", phase) + } + phase = 3 + return nil + } + r.handler(context.Background(), &whatsmeow.Client{})(&events.Message{ + Info: types.MessageInfo{MessageSource: types.MessageSource{Chat: types.NewJID("100000011111111", types.HiddenUserServer)}}, + Message: &waE2E.Message{Conversation: proto.String("ping")}, + }) + result := r.snapshot(false) + if phase != 4 || result.Database.Calls != 3 || len(result.Database.Queries) != 1 { + t.Fatalf("failed consent polluted database stats at phase %d: %+v", phase, result.Database) + } + if r.failed.Load() != 0 { + t.Fatalf("failed consent changed workload failures to %d", r.failed.Load()) + } +} + func TestHandlerUsesRunContextForPhoneConsent(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -359,3 +406,31 @@ func TestPhoneConsentTargetPrefersSenderLID(t *testing.T) { t.Fatalf("target = %s", got) } } + +func TestMergeDatabaseStatsRanksAfterCombiningAllQueries(t *testing.T) { + preflight := databaseStats{Queries: make([]queryStat, 31)} + workload := databaseStats{Queries: make([]queryStat, 31)} + for index := range 30 { + preflight.Queries[index] = queryStat{Query: "preflight-" + strconv.Itoa(index), Calls: 2} + workload.Queries[index] = queryStat{Query: "workload-" + strconv.Itoa(index), Calls: 2} + } + preflight.Queries[30] = queryStat{Query: "combined", Calls: 1} + workload.Queries[30] = queryStat{Query: "combined", Calls: 1} + + merged := mergeDatabaseStats(preflight, workload) + if len(merged.Queries) != 30 { + t.Fatalf("merged query count = %d, want 30", len(merged.Queries)) + } + found := false + for _, query := range merged.Queries { + if query.Query == "combined" { + found = true + if query.Calls != 2 { + t.Fatalf("combined calls = %d, want 2", query.Calls) + } + } + } + if !found { + t.Fatal("combined phase-local tail query was dropped before final ranking") + } +} diff --git a/benchmark/barback/cmd/bench/phone_consent_sync.go b/benchmark/barback/cmd/bench/phone_consent_sync.go new file mode 100644 index 000000000..f34946c26 --- /dev/null +++ b/benchmark/barback/cmd/bench/phone_consent_sync.go @@ -0,0 +1,9 @@ +//go:build !benchmark_legacy + +package main + +import "go.mau.fi/whatsmeow" + +func enablePhoneConsentReceiveBarrier(client *whatsmeow.Client) { + client.DangerousInternals().SetSynchronousMessageNameUpdates(true) +} diff --git a/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go b/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go new file mode 100644 index 000000000..eded51d14 --- /dev/null +++ b/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go @@ -0,0 +1,7 @@ +//go:build benchmark_legacy + +package main + +import "go.mau.fi/whatsmeow" + +func enablePhoneConsentReceiveBarrier(_ *whatsmeow.Client) {} diff --git a/client.go b/client.go index 0f32524ae..761363d64 100644 --- a/client.go +++ b/client.go @@ -114,9 +114,10 @@ type Client struct { // the client will not attempt to reconnect. The number of retries can be read from AutoReconnectErrors. AutoReconnectHook func(error) bool // If SynchronousAck is set, acks for messages will only be sent after all event handlers return. - SynchronousAck bool - EnableDecryptedEventBuffer bool - lastDecryptedBufferClear time.Time + SynchronousAck bool + EnableDecryptedEventBuffer bool + synchronousMessageNameUpdates atomic.Bool + lastDecryptedBufferClear time.Time DisableLoginAutoReconnect bool diff --git a/internals.go b/internals.go index 9a1201c16..0010f7efa 100644 --- a/internals.go +++ b/internals.go @@ -311,6 +311,10 @@ func (int *DangerousInternalClient) HandleEncryptedMessage(ctx context.Context, int.c.handleEncryptedMessage(ctx, node) } +func (int *DangerousInternalClient) SetSynchronousMessageNameUpdates(enabled bool) { + int.c.setSynchronousMessageNameUpdates(enabled) +} + func (int *DangerousInternalClient) HandleUnencryptedMessage(ctx context.Context, node *waBinary.Node) { int.c.handleUnencryptedMessage(ctx, node) } diff --git a/message.go b/message.go index 131de2e7b..6619099ec 100644 --- a/message.go +++ b/message.go @@ -52,12 +52,7 @@ func (cli *Client) handleEncryptedMessage(ctx context.Context, node *waBinary.No } else if !info.RecipientAlt.IsEmpty() { cli.StoreLIDPNMapping(ctx, info.RecipientAlt, info.Chat) } - if info.VerifiedName != nil && len(info.VerifiedName.Details.GetVerifiedName()) > 0 { - go cli.updateBusinessName(ctx, info.Sender, info.SenderAlt, info, info.VerifiedName.Details.GetVerifiedName()) - } - if len(info.PushName) > 0 && info.PushName != "-" && (cli.MessengerConfig == nil || info.PushName != "username") { - go cli.updatePushName(ctx, info.Sender, info.SenderAlt, info, info.PushName) - } + cli.updateMessageContactNames(ctx, info) if info.Sender.Server == types.NewsletterServer { var cancelled bool defer cli.maybeDeferredAck(ctx, node)(&cancelled) @@ -70,6 +65,32 @@ func (cli *Client) handleEncryptedMessage(ctx context.Context, node *waBinary.No } } +func (cli *Client) setSynchronousMessageNameUpdates(enabled bool) { + cli.synchronousMessageNameUpdates.Store(enabled) +} + +func (cli *Client) updateMessageContactNames(ctx context.Context, info *types.MessageInfo) { + synchronous := cli.synchronousMessageNameUpdates.Load() + var verifiedName string + if info.VerifiedName != nil { + verifiedName = info.VerifiedName.Details.GetVerifiedName() + } + if len(verifiedName) > 0 { + if synchronous { + cli.updateBusinessName(ctx, info.Sender, info.SenderAlt, info, verifiedName) + } else { + go cli.updateBusinessName(ctx, info.Sender, info.SenderAlt, info, verifiedName) + } + } + if len(info.PushName) > 0 && info.PushName != "-" && (cli.MessengerConfig == nil || info.PushName != "username") { + if synchronous { + cli.updatePushName(ctx, info.Sender, info.SenderAlt, info, info.PushName) + } else { + go cli.updatePushName(ctx, info.Sender, info.SenderAlt, info, info.PushName) + } + } +} + func (cli *Client) handleUnencryptedMessage(ctx context.Context, node *waBinary.Node) { info, err := cli.parseMessageInfo(node) if err != nil { diff --git a/message_name_updates_test.go b/message_name_updates_test.go new file mode 100644 index 000000000..0511a7aa4 --- /dev/null +++ b/message_name_updates_test.go @@ -0,0 +1,66 @@ +package whatsmeow + +import ( + "context" + "testing" + "time" + + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" +) + +type blockingMessageNameStore struct { + store.NoopStore + entered chan struct{} + release chan struct{} +} + +func TestMessageNameUpdatesRemainAsyncByDefault(t *testing.T) { + contacts := &blockingMessageNameStore{entered: make(chan struct{}), release: make(chan struct{})} + client := &Client{Store: &store.Device{Contacts: contacts}} + returned := make(chan struct{}) + go func() { + client.updateMessageContactNames(context.Background(), &types.MessageInfo{ + MessageSource: types.MessageSource{Sender: types.NewJID("15550001111", types.DefaultUserServer)}, + PushName: "Benchmark Sender", + }) + close(returned) + }() + + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("default message name update blocked on the store write") + } + <-contacts.entered + close(contacts.release) +} + +func (s *blockingMessageNameStore) PutPushName(context.Context, types.JID, string) (bool, string, error) { + close(s.entered) + <-s.release + return false, "", nil +} + +func TestSynchronousMessageNameUpdatesWaitForStore(t *testing.T) { + contacts := &blockingMessageNameStore{entered: make(chan struct{}), release: make(chan struct{})} + client := &Client{Store: &store.Device{Contacts: contacts}} + client.setSynchronousMessageNameUpdates(true) + done := make(chan struct{}) + go func() { + client.updateMessageContactNames(context.Background(), &types.MessageInfo{ + MessageSource: types.MessageSource{Sender: types.NewJID("15550001111", types.DefaultUserServer)}, + PushName: "Benchmark Sender", + }) + close(done) + }() + + <-contacts.entered + select { + case <-done: + t.Fatal("synchronous message name update returned before the store write") + default: + } + close(contacts.release) + <-done +} From 33de930fbc9cf281fd85843e2e1b6c0184ffb2d1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 13:41:11 +0300 Subject: [PATCH 124/163] Detect consent benchmark compatibility --- benchmark/barback/run-comparison-matrix.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/benchmark/barback/run-comparison-matrix.sh b/benchmark/barback/run-comparison-matrix.sh index 96b0d24fb..8c37b9cdf 100755 --- a/benchmark/barback/run-comparison-matrix.sh +++ b/benchmark/barback/run-comparison-matrix.sh @@ -29,6 +29,9 @@ if [[ ! -v CANDIDATE_BUILD_TAGS ]]; then if [[ -f "$benchmark_dir/cmd/bench/security_code_smoke.go" ]]; then required_candidate_symbols+=('func (cli *Client) GetIdentityVerificationCodes(') fi + if [[ -f "$benchmark_dir/cmd/bench/phone_consent_sync.go" ]]; then + required_candidate_symbols+=('func (int *DangerousInternalClient) SetSynchronousMessageNameUpdates(') + fi for symbol in "${required_candidate_symbols[@]}"; do if ! git -C "$repo_dir" grep -Fq "$symbol" "$candidate_sha" -- '*.go'; then candidate_build_tags=benchmark_legacy From a86e0158525c836a0e9704d1a7b99aa40ea498fc Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 18:45:04 +0300 Subject: [PATCH 125/163] Add LID identity verification codes --- security_code.go | 323 +++++++++++++++++++++++++ security_code_test.go | 234 ++++++++++++++++++ store/noop.go | 4 + store/sqlstore/identity_reader_test.go | 111 +++++++++ store/sqlstore/store.go | 83 ++++++- store/store.go | 4 + 6 files changed, 756 insertions(+), 3 deletions(-) create mode 100644 security_code.go create mode 100644 security_code_test.go create mode 100644 store/sqlstore/identity_reader_test.go diff --git a/security_code.go b/security_code.go new file mode 100644 index 000000000..ecc797b38 --- /dev/null +++ b/security_code.go @@ -0,0 +1,323 @@ +package whatsmeow + +import ( + "bytes" + "context" + "crypto/sha512" + "errors" + "fmt" + "slices" + + "google.golang.org/protobuf/proto" + + "go.mau.fi/whatsmeow/proto/waFingerprint" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" +) + +const securityCodeIterations = 5200 + +var ( + ErrIdentityKeyReaderUnsupported = errors.New("identity store does not support batch identity-key reads") + ErrIdentityVerificationRequiresLID = errors.New("identity verification requires an @lid user ID") +) + +type IdentityVerificationCodes struct { + UserID types.JID + PhoneNumber types.JID + Username string + NumericCode string + DisplayQRCode []byte + VerificationQRCode []byte +} + +type identityVerificationFingerprint struct { + LID types.JID + Phone types.JID + Username string + Keys [][32]byte + Hosted bool +} + +func newIdentityVerificationCodes(ctx context.Context, local, remote identityVerificationFingerprint) (*IdentityVerificationCodes, error) { + numericCode, err := generateNumericSecurityCode(ctx, []byte(local.LID.User), local.Keys, []byte(remote.LID.User), remote.Keys) + if err != nil { + return nil, err + } + displayQR, verificationQR, err := buildIdentityVerificationQRCodes(local, remote) + if err != nil { + return nil, err + } + return &IdentityVerificationCodes{ + UserID: remote.LID, + PhoneNumber: remote.Phone, + Username: remote.Username, + NumericCode: numericCode, + DisplayQRCode: displayQR, + VerificationQRCode: verificationQR, + }, nil +} + +func (cli *Client) GetIdentityVerificationCodes(ctx context.Context, userID types.JID) (*IdentityVerificationCodes, error) { + if cli == nil || cli.Store == nil { + return nil, ErrClientIsNil + } + userID = userID.ToNonAD() + if userID.Server != types.HiddenUserServer || userID.User == "" { + return nil, ErrIdentityVerificationRequiresLID + } + localLID := cli.Store.LID.ToNonAD() + if localLID.Server != types.HiddenUserServer || localLID.User == "" { + return nil, errors.New("local LID is unavailable") + } + if localLID == userID { + return nil, errors.New("cannot generate an identity verification code for the local user") + } + if cli.Store.IdentityKey == nil || cli.Store.IdentityKey.Pub == nil { + return nil, errors.New("local identity key is unavailable") + } + + devices, err := cli.GetUserDevices(ctx, []types.JID{localLID, userID}) + if err != nil { + return nil, fmt.Errorf("get identity verification devices: %w", err) + } + localDevices := make([]types.JID, 0, len(devices)) + remoteDevices := make([]types.JID, 0, len(devices)) + for _, device := range devices { + switch { + case device.User == localLID.User && (device.Server == types.HiddenUserServer || device.Server == types.HostedLIDServer): + localDevices = append(localDevices, device) + case device.User == userID.User && (device.Server == types.HiddenUserServer || device.Server == types.HostedLIDServer): + remoteDevices = append(remoteDevices, device) + } + } + if len(remoteDevices) == 0 { + return nil, fmt.Errorf("no devices found for %s", userID) + } + localKeys := make([][32]byte, 1, len(localDevices)+1) + localKeys[0] = *cli.Store.IdentityKey.Pub + otherLocalKeys, err := cli.readIdentityKeys(ctx, localDevices) + if err != nil { + return nil, fmt.Errorf("read local device identities: %w", err) + } + localKeys = append(localKeys, otherLocalKeys...) + remoteKeys, err := cli.readIdentityKeys(ctx, remoteDevices) + if err != nil { + return nil, fmt.Errorf("read remote device identities: %w", err) + } + + local := identityVerificationFingerprint{LID: localLID, Keys: localKeys} + remote := identityVerificationFingerprint{LID: userID, Keys: remoteKeys} + if cli.Store.ID != nil && cli.Store.ID.Server == types.DefaultUserServer { + local.Phone = cli.Store.ID.ToNonAD() + } + if cli.Store.LIDs != nil { + remote.Phone, err = cli.Store.LIDs.GetPNForLID(ctx, userID) + if err != nil { + cli.Log.Warnf("Failed to get phone-number alias for %s: %v", userID, err) + remote.Phone = types.EmptyJID + } + } + if cli.Store.Contacts != nil { + if contact, contactErr := cli.Store.Contacts.GetContact(ctx, localLID); contactErr == nil { + local.Username = contact.Username + } + if contact, contactErr := cli.Store.Contacts.GetContact(ctx, userID); contactErr == nil { + remote.Username = contact.Username + } + } + return newIdentityVerificationCodes(ctx, local, remote) +} + +func (cli *Client) readIdentityKeys(ctx context.Context, devices []types.JID) ([][32]byte, error) { + if cli == nil || cli.Store == nil { + return nil, ErrClientIsNil + } + reader, ok := cli.Store.Identities.(store.IdentityKeyReader) + if !ok { + return nil, ErrIdentityKeyReaderUnsupported + } + addresses := make([]string, 0, len(devices)) + deviceByAddress := make(map[string]types.JID, len(devices)) + for _, device := range devices { + address := device.SignalAddress().String() + if _, exists := deviceByAddress[address]; exists { + continue + } + addresses = append(addresses, address) + deviceByAddress[address] = device + } + stored, err := reader.GetManyIdentities(ctx, addresses) + if err != nil { + return nil, fmt.Errorf("read identity keys: %w", err) + } + missing := make([]types.JID, 0, len(addresses)-len(stored)) + for _, address := range addresses { + if _, exists := stored[address]; !exists { + missing = append(missing, deviceByAddress[address]) + } + } + if len(missing) > 0 { + responses, fetchErr := cli.fetchPreKeys(ctx, missing) + if fetchErr != nil { + return nil, fetchErr + } + for _, device := range missing { + response, exists := responses[device] + if !exists { + return nil, fmt.Errorf("identity key missing from prekey response for %s", device) + } + if response.err != nil { + return nil, fmt.Errorf("fetch identity key for %s: %w", device, response.err) + } + key := response.bundle.IdentityKey().PublicKey().PublicKey() + address := device.SignalAddress().String() + trusted, trustErr := cli.Store.Identities.IsTrustedIdentity(ctx, address, key) + if trustErr != nil { + return nil, fmt.Errorf("check identity key for %s: %w", device, trustErr) + } + if !trusted { + return nil, fmt.Errorf("identity key for %s is not trusted", device) + } + if putErr := cli.Store.Identities.PutIdentity(ctx, address, key); putErr != nil { + return nil, fmt.Errorf("store identity key for %s: %w", device, putErr) + } + stored[address] = key + } + } + keys := make([][32]byte, 0, len(addresses)) + for _, address := range addresses { + key, exists := stored[address] + if !exists { + return nil, fmt.Errorf("identity key unavailable for %s", deviceByAddress[address]) + } + keys = append(keys, key) + } + return keys, nil +} + +func generateNumericSecurityCode( + ctx context.Context, + localIdentifier []byte, + localKeys [][32]byte, + remoteIdentifier []byte, + remoteKeys [][32]byte, +) (string, error) { + local, err := generateSecurityCodeFingerprint(ctx, localIdentifier, serializeIdentityKeys(localKeys)) + if err != nil { + return "", err + } + remote, err := generateSecurityCodeFingerprint(ctx, remoteIdentifier, serializeIdentityKeys(remoteKeys)) + if err != nil { + return "", err + } + if local < remote { + return local + remote, nil + } + return remote + local, nil +} + +func serializeIdentityKeys(keys [][32]byte) []byte { + keys = slices.Clone(keys) + slices.SortFunc(keys, func(left, right [32]byte) int { + return bytes.Compare(left[:], right[:]) + }) + serialized := make([]byte, 0, len(keys)*33) + for _, key := range keys { + serialized = append(serialized, 0x05) + serialized = append(serialized, key[:]...) + } + return serialized +} + +func buildIdentityVerificationQRCodes(local, remote identityVerificationFingerprint) ([]byte, []byte, error) { + if err := validateIdentityVerificationFingerprint(local); err != nil { + return nil, nil, fmt.Errorf("invalid local fingerprint: %w", err) + } + if err := validateIdentityVerificationFingerprint(remote); err != nil { + return nil, nil, fmt.Errorf("invalid remote fingerprint: %w", err) + } + display, err := marshalCombinedFingerprint(local, remote, false) + if err != nil { + return nil, nil, err + } + verify, err := marshalCombinedFingerprint(local, remote, true) + if err != nil { + return nil, nil, err + } + return display, verify, nil +} + +func validateIdentityVerificationFingerprint(fingerprint identityVerificationFingerprint) error { + if fingerprint.LID.Server != types.HiddenUserServer || fingerprint.LID.User == "" { + return errors.New("LID is required") + } + if !fingerprint.Phone.IsEmpty() && fingerprint.Phone.Server != types.DefaultUserServer { + return errors.New("phone number must be a phone-number JID") + } + if len(fingerprint.Keys) == 0 { + return errors.New("at least one identity key is required") + } + return nil +} + +func marshalCombinedFingerprint(local, remote identityVerificationFingerprint, includeUnhashed bool) ([]byte, error) { + return proto.Marshal(&waFingerprint.CombinedFingerprint{ + Version: proto.Uint32(1), + LocalFingerprint: buildFingerprintData(local, includeUnhashed), + RemoteFingerprint: buildFingerprintData(remote, includeUnhashed), + }) +} + +func buildFingerprintData(fingerprint identityVerificationFingerprint, includeUnhashed bool) *waFingerprint.FingerprintData { + serialized := serializeIdentityKeys(fingerprint.Keys) + hash := sha512.Sum512(serialized) + hostedState := waFingerprint.HostedState_E2EE + if fingerprint.Hosted { + hostedState = waFingerprint.HostedState_HOSTED + } + data := &waFingerprint.FingerprintData{ + LidIdentifier: []byte(fingerprint.LID.String()), + UsernameIdentifier: []byte(fingerprint.Username), + HostedState: &hostedState, + HashedPublicKey: hash[:], + } + if !fingerprint.Phone.IsEmpty() { + data.PnIdentifier = []byte(fingerprint.Phone.User) + } + if includeUnhashed { + data.PublicKey = serialized + } + return data +} + +func generateSecurityCodeFingerprint(ctx context.Context, identifier, keys []byte) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + input := make([]byte, 2, 2+len(keys)+len(identifier)) + input = append(input, keys...) + input = append(input, identifier...) + digest := sha512.Sum512(input) + input = make([]byte, 0, len(digest)+len(keys)) + for index := 0; index < securityCodeIterations; index++ { + if index%64 == 0 { + if err := ctx.Err(); err != nil { + return "", err + } + } + input = append(input[:0], digest[:]...) + input = append(input, keys...) + digest = sha512.Sum512(input) + } + code := make([]byte, 0, 30) + for offset := 0; offset < 30; offset += 5 { + value := uint64(digest[offset])<<32 | + uint64(digest[offset+1])<<24 | + uint64(digest[offset+2])<<16 | + uint64(digest[offset+3])<<8 | + uint64(digest[offset+4]) + code = fmt.Appendf(code, "%05d", value%100000) + } + return string(code), nil +} diff --git a/security_code_test.go b/security_code_test.go new file mode 100644 index 000000000..8f2fcef3c --- /dev/null +++ b/security_code_test.go @@ -0,0 +1,234 @@ +package whatsmeow + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "slices" + "testing" + + "google.golang.org/protobuf/proto" + + "go.mau.fi/whatsmeow/proto/waFingerprint" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" +) + +type identityReaderStore struct { + keys map[string][32]byte +} + +func (*identityReaderStore) PutIdentity(context.Context, string, [32]byte) error { return nil } +func (*identityReaderStore) DeleteAllIdentities(context.Context, string) error { return nil } +func (*identityReaderStore) DeleteIdentity(context.Context, string) error { return nil } +func (*identityReaderStore) IsTrustedIdentity(context.Context, string, [32]byte) (bool, error) { + return true, nil +} +func (irs *identityReaderStore) GetManyIdentities(_ context.Context, addresses []string) (map[string][32]byte, error) { + result := make(map[string][32]byte, len(addresses)) + for _, address := range addresses { + if key, ok := irs.keys[address]; ok { + result[address] = key + } + } + return result, nil +} + +var _ store.IdentityKeyReader = (*identityReaderStore)(nil) + +func TestGenerateNumericSecurityCodeMatchesWhatsAppWebV4(t *testing.T) { + localKeys := [][32]byte{ + *(*[32]byte)(bytes.Repeat([]byte{0x22}, 32)), + *(*[32]byte)(bytes.Repeat([]byte{0x11}, 32)), + } + remoteKeys := [][32]byte{ + *(*[32]byte)(bytes.Repeat([]byte{0x33}, 32)), + } + + got, err := generateNumericSecurityCode( + context.Background(), + []byte("100000000000001"), + localKeys, + []byte("100000000000002"), + remoteKeys, + ) + if err != nil { + t.Fatal(err) + } + const want = "225825860855586870704874202827422423772749730831393050598207" + if got != want { + t.Fatalf("security code = %q, want %q", got, want) + } +} + +func TestGenerateNumericSecurityCodeHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := generateNumericSecurityCode( + ctx, + []byte("100000000000001"), + [][32]byte{{}}, + []byte("100000000000002"), + [][32]byte{{}}, + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func TestBuildIdentityVerificationQRCodesMatchesWhatsAppWebV3(t *testing.T) { + local := identityVerificationFingerprint{ + LID: types.NewJID("100000000000001", types.HiddenUserServer), + Phone: types.NewJID("15550000001", types.DefaultUserServer), + Username: "local_user", + Keys: [][32]byte{ + *(*[32]byte)(bytes.Repeat([]byte{0x22}, 32)), + *(*[32]byte)(bytes.Repeat([]byte{0x11}, 32)), + }, + } + remote := identityVerificationFingerprint{ + LID: types.NewJID("100000000000002", types.HiddenUserServer), + Phone: types.NewJID("15550000002", types.DefaultUserServer), + Username: "remote_user", + Keys: [][32]byte{ + *(*[32]byte)(bytes.Repeat([]byte{0x33}, 32)), + }, + } + + displayBytes, verifyBytes, err := buildIdentityVerificationQRCodes(local, remote) + if err != nil { + t.Fatal(err) + } + var display, verify waFingerprint.CombinedFingerprint + if err = proto.Unmarshal(displayBytes, &display); err != nil { + t.Fatal(err) + } + if err = proto.Unmarshal(verifyBytes, &verify); err != nil { + t.Fatal(err) + } + if display.GetVersion() != 1 || verify.GetVersion() != 1 { + t.Fatalf("unexpected versions: display=%d verify=%d", display.GetVersion(), verify.GetVersion()) + } + assertFingerprintIdentifiers(t, display.GetLocalFingerprint(), local) + assertFingerprintIdentifiers(t, display.GetRemoteFingerprint(), remote) + if len(display.GetLocalFingerprint().GetPublicKey()) != 0 || len(display.GetRemoteFingerprint().GetPublicKey()) != 0 { + t.Fatal("display QR exposed unhashed identity keys") + } + localSerialized := serializeIdentityKeys(local.Keys) + remoteSerialized := serializeIdentityKeys(remote.Keys) + if !bytes.Equal(verify.GetLocalFingerprint().GetPublicKey(), localSerialized) || + !bytes.Equal(verify.GetRemoteFingerprint().GetPublicKey(), remoteSerialized) { + t.Fatal("verification QR did not contain the sorted identity key sets") + } + const localHash = "9448c8bd61d1029632a6d2bba3ed50a23cfb85fd6900cae6d7b7248514291e9b28b32a5f48e9bedb826d8fd64fc6ae004ca9abb68f0e893d0c8d927ac41598d5" + const remoteHash = "02368e44bee980c294e96347298f06b584ca133fb617600b5004f6434330e3bb7c2339fd8d492d4541bd2f80bec1b8528518a58c896fdc7918d8cc599eecdc0a" + if hex.EncodeToString(display.GetLocalFingerprint().GetHashedPublicKey()) != localHash || + hex.EncodeToString(display.GetRemoteFingerprint().GetHashedPublicKey()) != remoteHash { + t.Fatal("display QR identity-key hashes do not match WhatsApp Web") + } +} + +func TestBuildIdentityVerificationQRCodesRejectsMissingKeys(t *testing.T) { + _, _, err := buildIdentityVerificationQRCodes( + identityVerificationFingerprint{LID: types.NewJID("100000000000001", types.HiddenUserServer)}, + identityVerificationFingerprint{LID: types.NewJID("100000000000002", types.HiddenUserServer)}, + ) + if err == nil { + t.Fatal("expected missing identity keys to fail") + } +} + +func TestNewIdentityVerificationCodesUsesLIDAsUserID(t *testing.T) { + local := identityVerificationFingerprint{ + LID: types.NewJID("100000000000001", types.HiddenUserServer), + Keys: [][32]byte{*(*[32]byte)(bytes.Repeat([]byte{0x11}, 32))}, + } + remote := identityVerificationFingerprint{ + LID: types.NewJID("100000000000002", types.HiddenUserServer), + Phone: types.NewJID("15550000002", types.DefaultUserServer), + Username: "remote_user", + Keys: [][32]byte{*(*[32]byte)(bytes.Repeat([]byte{0x22}, 32))}, + } + + got, err := newIdentityVerificationCodes(context.Background(), local, remote) + if err != nil { + t.Fatal(err) + } + if got.UserID != remote.LID || got.PhoneNumber != remote.Phone || got.Username != remote.Username { + t.Fatalf("unexpected identity aliases: %#v", got) + } + if len(got.NumericCode) != 60 || len(got.DisplayQRCode) == 0 || len(got.VerificationQRCode) == 0 { + t.Fatalf("incomplete security-code result: %#v", got) + } +} + +func TestGetIdentityVerificationCodesRequiresLID(t *testing.T) { + client := &Client{Store: &store.Device{}} + _, err := client.GetIdentityVerificationCodes( + context.Background(), + types.NewJID("15550000002", types.DefaultUserServer), + ) + if !errors.Is(err, ErrIdentityVerificationRequiresLID) { + t.Fatalf("error = %v, want ErrIdentityVerificationRequiresLID", err) + } +} + +func TestReadIdentityKeysUsesOptionalBatchReader(t *testing.T) { + devices := []types.JID{ + types.NewADJID("100000000000001", types.LIDDomain, 1), + types.NewADJID("100000000000001", types.LIDDomain, 2), + } + want := [][32]byte{ + *(*[32]byte)(bytes.Repeat([]byte{0x11}, 32)), + *(*[32]byte)(bytes.Repeat([]byte{0x22}, 32)), + } + identityStore := &identityReaderStore{keys: map[string][32]byte{ + devices[0].SignalAddress().String(): want[0], + devices[1].SignalAddress().String(): want[1], + }} + client := &Client{Store: &store.Device{Identities: identityStore}} + + got, err := client.readIdentityKeys(context.Background(), devices) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, want) { + t.Fatalf("identity keys = %#v, want %#v", got, want) + } +} + +func TestReadIdentityKeysRequiresOptionalBatchReader(t *testing.T) { + client := &Client{Store: &store.Device{Identities: &identityStoreWithoutReader{}}} + _, err := client.readIdentityKeys(context.Background(), []types.JID{ + types.NewADJID("100000000000001", types.LIDDomain, 1), + }) + if !errors.Is(err, ErrIdentityKeyReaderUnsupported) { + t.Fatalf("error = %v, want ErrIdentityKeyReaderUnsupported", err) + } +} + +type identityStoreWithoutReader struct{} + +func (*identityStoreWithoutReader) PutIdentity(context.Context, string, [32]byte) error { return nil } +func (*identityStoreWithoutReader) DeleteAllIdentities(context.Context, string) error { return nil } +func (*identityStoreWithoutReader) DeleteIdentity(context.Context, string) error { return nil } +func (*identityStoreWithoutReader) IsTrustedIdentity(context.Context, string, [32]byte) (bool, error) { + return true, nil +} + +func assertFingerprintIdentifiers(t *testing.T, got *waFingerprint.FingerprintData, want identityVerificationFingerprint) { + t.Helper() + if got == nil { + t.Fatal("missing fingerprint") + } + if string(got.GetLidIdentifier()) != want.LID.String() { + t.Fatalf("LID identifier = %q, want %q", got.GetLidIdentifier(), want.LID.String()) + } + if string(got.GetPnIdentifier()) != want.Phone.User { + t.Fatalf("phone identifier = %q, want %q", got.GetPnIdentifier(), want.Phone.User) + } + if string(got.GetUsernameIdentifier()) != want.Username { + t.Fatalf("username identifier = %q, want %q", got.GetUsernameIdentifier(), want.Username) + } +} diff --git a/store/noop.go b/store/noop.go index 80a7c8f00..53dfdde75 100644 --- a/store/noop.go +++ b/store/noop.go @@ -61,6 +61,10 @@ func (n *NoopStore) IsTrustedIdentity(ctx context.Context, address string, key [ return false, n.Error } +func (n *NoopStore) GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, error) { + return nil, n.Error +} + func (n *NoopStore) GetSession(ctx context.Context, address string) ([]byte, error) { return nil, n.Error } diff --git a/store/sqlstore/identity_reader_test.go b/store/sqlstore/identity_reader_test.go new file mode 100644 index 000000000..e5e2c17b6 --- /dev/null +++ b/store/sqlstore/identity_reader_test.go @@ -0,0 +1,111 @@ +package sqlstore + +import ( + "bytes" + "context" + "database/sql" + "database/sql/driver" + "errors" + "io" + "testing" + + "go.mau.fi/whatsmeow/types" +) + +type identityReaderState struct { + queries int +} + +type identityReaderConnector struct { + state *identityReaderState +} + +func (c *identityReaderConnector) Connect(context.Context) (driver.Conn, error) { + return &identityReaderConn{state: c.state}, nil +} + +func (*identityReaderConnector) Driver() driver.Driver { + return identityReaderDriver{} +} + +type identityReaderDriver struct{} + +func (identityReaderDriver) Open(string) (driver.Conn, error) { + return nil, errors.New("use connector") +} + +type identityReaderConn struct { + state *identityReaderState +} + +func (*identityReaderConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} + +func (*identityReaderConn) Close() error { + return nil +} + +func (*identityReaderConn) Begin() (driver.Tx, error) { + return nil, errors.New("unexpected transaction") +} + +func (c *identityReaderConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + c.state.queries++ + return &identityReaderRows{}, nil +} + +type identityReaderRows struct { + index int +} + +func (*identityReaderRows) Columns() []string { + return []string{"their_id", "identity"} +} + +func (*identityReaderRows) Close() error { + return nil +} + +func (r *identityReaderRows) Next(values []driver.Value) error { + rows := []struct { + address string + key byte + }{ + {address: "100000000000001:1", key: 0x11}, + {address: "100000000000001:2", key: 0x22}, + } + if r.index >= len(rows) { + return io.EOF + } + row := rows[r.index] + r.index++ + values[0] = row.address + values[1] = bytes.Repeat([]byte{row.key}, 32) + return nil +} + +func TestGetManyIdentitiesUsesOneQueryAndCachesResults(t *testing.T) { + state := &identityReaderState{} + db := sql.OpenDB(&identityReaderConnector{state: state}) + t.Cleanup(func() { _ = db.Close() }) + store := NewSQLStore( + NewWithDB(db, "sqlite3", nil), + types.NewJID("100000000000000", types.HiddenUserServer), + ) + addresses := []string{"100000000000001:1", "100000000000001:2"} + + got, err := store.GetManyIdentities(context.Background(), addresses) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[addresses[0]][0] != 0x11 || got[addresses[1]][0] != 0x22 { + t.Fatalf("unexpected identities: %#v", got) + } + if _, err = store.GetManyIdentities(context.Background(), addresses); err != nil { + t.Fatal(err) + } + if state.queries != 1 { + t.Fatalf("database queries = %d, want 1", state.queries) + } +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 327a3cfe5..e9b964634 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -136,9 +136,11 @@ const ( INSERT INTO whatsmeow_identity_keys (our_jid, their_id, identity) VALUES ($1, $2, $3) ON CONFLICT (our_jid, their_id) DO UPDATE SET identity=excluded.identity ` - deleteAllIdentitiesQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2` - deleteIdentityQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2` - getIdentityQuery = `SELECT identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2` + deleteAllIdentitiesQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2` + deleteIdentityQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2` + getIdentityQuery = `SELECT identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2` + getManyIdentityQueryPostgres = `SELECT their_id, identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id = ANY($2)` + getManyIdentityQueryGeneric = `SELECT their_id, identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id IN (%s)` ) func (s *SQLStore) PutIdentity(ctx context.Context, address string, key [32]byte) error { @@ -212,6 +214,81 @@ func (s *SQLStore) IsTrustedIdentity(ctx context.Context, address string, key [3 return existingKey == key, nil } +type addressIdentityTuple struct { + Address string + Identity []byte +} + +var identityScanner = dbutil.ConvertRowFn[addressIdentityTuple](func(row dbutil.Scannable) (out addressIdentityTuple, err error) { + err = row.Scan(&out.Address, &out.Identity) + return +}) + +func (s *SQLStore) queryManyIdentities(ctx context.Context, addresses []string) (dbutil.Rows, error) { + if wrapped, ok := wrapPostgresArray(s.db, addresses); ok { + return s.db.Query(ctx, getManyIdentityQueryPostgres, s.JID, wrapped) + } + args := make([]any, len(addresses)+1) + placeholders := make([]string, len(addresses)) + args[0] = s.JID + for i, address := range addresses { + args[i+1] = address + placeholders[i] = fmt.Sprintf("$%d", i+2) + } + return s.db.Query(ctx, fmt.Sprintf(getManyIdentityQueryGeneric, strings.Join(placeholders, ",")), args...) +} + +func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, error) { + if len(addresses) == 0 { + return nil, nil + } + result := make(map[string][32]byte, len(addresses)) + missing := make([]string, 0, len(addresses)) + seen := make(map[string]struct{}, len(addresses)) + s.identityCacheLock.RLock() + for _, address := range addresses { + if _, duplicate := seen[address]; duplicate { + continue + } + seen[address] = struct{}{} + if cached, ok := s.identityCache[address]; ok { + if cached.Present { + result[address] = cached.Key + } + } else { + missing = append(missing, address) + } + } + s.identityCacheLock.RUnlock() + if len(missing) == 0 { + return result, nil + } + + rows, err := s.queryManyIdentities(ctx, missing) + fetched := make(map[string]identityCacheEntry, len(missing)) + for _, address := range missing { + fetched[address] = identityCacheEntry{} + } + err = identityScanner.NewRowIter(rows, err).Iter(func(tuple addressIdentityTuple) (bool, error) { + if len(tuple.Identity) != 32 { + return false, ErrInvalidLength + } + key := *(*[32]byte)(tuple.Identity) + fetched[tuple.Address] = identityCacheEntry{Key: key, Present: true} + result[tuple.Address] = key + return true, nil + }) + if err != nil { + return nil, err + } + s.identityCacheLock.Lock() + for address, entry := range fetched { + s.setCachedIdentityLocked(address, entry) + } + s.identityCacheLock.Unlock() + return result, nil +} + const ( getSessionQuery = `SELECT session FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id=$2` hasSessionQuery = `SELECT true FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id=$2` diff --git a/store/store.go b/store/store.go index 6bdc39931..e88f7ab66 100644 --- a/store/store.go +++ b/store/store.go @@ -28,6 +28,10 @@ type IdentityStore interface { IsTrustedIdentity(ctx context.Context, address string, key [32]byte) (bool, error) } +type IdentityKeyReader interface { + GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, error) +} + type SessionStore interface { GetSession(ctx context.Context, address string) ([]byte, error) HasSession(ctx context.Context, address string) (bool, error) From 3ca82abafec3ecb4c8ca2107bd54df022606431f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 18:52:00 +0300 Subject: [PATCH 126/163] Validate security codes with Barback --- benchmark/barback/cmd/bench/main.go | 139 +++++++++++++++++++---- benchmark/barback/cmd/bench/main_test.go | 38 ++++++- benchmark/barback/compose.yaml | 1 + 3 files changed, 151 insertions(+), 27 deletions(-) diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 6dc96089e..91932a880 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -30,6 +30,7 @@ import ( "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/proto/waFingerprint" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" @@ -51,6 +52,7 @@ type config struct { Variant string BusinessSmoke bool PhoneConsentSmoke bool + SecurityCodeSmoke bool Total int64 Timeout time.Duration Workload workloadConfig @@ -134,6 +136,7 @@ type result struct { MediaUploadLatency latencyStats `json:"media_upload_latency"` BusinessAppValidated bool `json:"business_app_validated"` PhoneConsentValidated bool `json:"phone_consent_validated"` + SecurityCodeValidated bool `json:"security_code_validated"` } type runner struct { @@ -158,6 +161,11 @@ type runner struct { phoneConsentValidator func(context.Context, *whatsmeow.Client, types.JID) error statementStatsReset func(context.Context) error statementStatsSnapshot func() databaseStats + securityCodeValid atomic.Bool + securityCodeOnce sync.Once + securityCodeErr error + preflightOnce sync.Once + preflightErr error startOnce sync.Once doneOnce sync.Once @@ -289,6 +297,10 @@ func loadConfig() (config, error) { if err != nil { return config{}, fmt.Errorf("invalid BENCH_PHONE_CONSENT_SMOKE") } + securityCodeSmoke, err := strconv.ParseBool(env("BENCH_SECURITY_CODE_SMOKE", "false")) + if err != nil { + return config{}, fmt.Errorf("invalid BENCH_SECURITY_CODE_SMOKE") + } return config{ DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@postgres:5432/hypermeow?sslmode=disable"), BarbackURL: env("BARBACK_URL", "http://barback:8080"), @@ -300,6 +312,7 @@ func loadConfig() (config, error) { Variant: env("BENCH_VARIANT", "candidate"), BusinessSmoke: businessSmoke, PhoneConsentSmoke: phoneConsentSmoke, + SecurityCodeSmoke: securityCodeSmoke, Total: total, Timeout: timeout, Workload: workloadConfig{ @@ -492,28 +505,19 @@ func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeo if event.Message.GetConversation() != "ping" { return } - if !r.beforeBenchmarkMessage(func() error { - preflightDatabase := r.snapshotStatementStats() - r.metricsMu.Lock() - r.preflightDatabase = preflightDatabase - r.preflightCaptured = true - r.metricsMu.Unlock() - validate := r.phoneConsentValidator - if validate == nil { - validate = r.validatePhoneNumberConsent - } - validationErr := validate(ctx, client, phoneConsentTarget(event)) - resetErr := r.resetStatementStats(ctx) - r.metricsMu.Lock() - r.postConsentClean = resetErr == nil - r.metricsMu.Unlock() - if validationErr != nil { - return validationErr - } - if resetErr != nil { - return fmt.Errorf("reset workload statement stats: %w", resetErr) - } - return nil + validatePhoneConsent := r.phoneConsentValidator + if validatePhoneConsent == nil { + validatePhoneConsent = r.validatePhoneNumberConsent + } + if !r.runBenchmarkPreflight(ctx, func() bool { + return r.beforeBenchmarkMessage( + func() error { + return validatePhoneConsent(ctx, client, phoneConsentTarget(event)) + }, + func() error { + return validateIdentityVerificationCodes(ctx, client, phoneConsentTarget(event)) + }, + ) }) { return } @@ -668,11 +672,48 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs } } -func (r *runner) beforeBenchmarkMessage(validate func() error) bool { - if !r.cfg.PhoneConsentSmoke { +func (r *runner) runBenchmarkPreflight(ctx context.Context, validate func() bool) bool { + if !r.cfg.PhoneConsentSmoke && !r.cfg.SecurityCodeSmoke { return true } - return r.runPhoneConsentSmoke(validate) == nil + r.preflightOnce.Do(func() { + preflightDatabase := r.snapshotStatementStats() + r.metricsMu.Lock() + r.preflightDatabase = preflightDatabase + r.preflightCaptured = true + r.metricsMu.Unlock() + valid := validate() + resetErr := r.resetStatementStats(ctx) + r.metricsMu.Lock() + r.postConsentClean = resetErr == nil + r.metricsMu.Unlock() + if !valid { + r.preflightErr = errors.New("benchmark smoke validation failed") + } + if resetErr != nil { + r.recordFailure(resetErr) + if r.preflightErr == nil { + r.preflightErr = fmt.Errorf("reset workload statement stats: %w", resetErr) + } + r.reportFatal(fmt.Errorf("reset workload statement stats: %w", resetErr)) + } + }) + return r.preflightErr == nil +} + +func (r *runner) beforeBenchmarkMessage(validatePhoneConsent func() error, validateSecurityCodes ...func() error) bool { + if r.cfg.PhoneConsentSmoke && r.runPhoneConsentSmoke(validatePhoneConsent) != nil { + return false + } + if r.cfg.SecurityCodeSmoke { + if len(validateSecurityCodes) == 0 { + return false + } + if r.runSecurityCodeSmoke(validateSecurityCodes[0]) != nil { + return false + } + } + return true } func (r *runner) runPhoneConsentSmoke(validate func() error) error { @@ -689,6 +730,21 @@ func (r *runner) runPhoneConsentSmoke(validate func() error) error { return r.phoneConsentErr } +func (r *runner) runSecurityCodeSmoke(validate func() error) error { + r.securityCodeOnce.Do(func() { + r.securityCodeErr = validate() + if r.securityCodeErr == nil { + r.securityCodeValid.Store(true) + return + } + r.failed.Add(1) + r.recordFailure(r.securityCodeErr) + fmt.Fprintf(os.Stderr, "validate identity verification code: %v\n", r.securityCodeErr) + r.reportFatal(fmt.Errorf("validate identity verification code: %w", r.securityCodeErr)) + }) + return r.securityCodeErr +} + func (r *runner) reportFatal(err error) { if err == nil || r.fatalErr == nil { return @@ -699,6 +755,38 @@ func (r *runner) reportFatal(err error) { } } +func validateIdentityVerificationCodes(ctx context.Context, client *whatsmeow.Client, userID types.JID) error { + if userID.Server != types.HiddenUserServer { + return whatsmeow.ErrIdentityVerificationRequiresLID + } + codes, err := client.GetIdentityVerificationCodes(ctx, userID) + if err != nil { + return fmt.Errorf("generate identity verification codes: %w", err) + } + if codes.UserID != userID || len(codes.NumericCode) != 60 { + return errors.New("identity verification result has an invalid LID or numeric code") + } + for _, digit := range codes.NumericCode { + if digit < '0' || digit > '9' { + return errors.New("identity verification numeric code contains non-digits") + } + } + var display, verification waFingerprint.CombinedFingerprint + if err = proto.Unmarshal(codes.DisplayQRCode, &display); err != nil { + return fmt.Errorf("decode display QR: %w", err) + } + if err = proto.Unmarshal(codes.VerificationQRCode, &verification); err != nil { + return fmt.Errorf("decode verification QR: %w", err) + } + if len(display.GetLocalFingerprint().GetPublicKey()) != 0 || len(display.GetRemoteFingerprint().GetPublicKey()) != 0 { + return errors.New("display QR exposed unhashed identity keys") + } + if len(verification.GetLocalFingerprint().GetPublicKey()) == 0 || len(verification.GetRemoteFingerprint().GetPublicKey()) == 0 { + return errors.New("verification QR omitted identity keys") + } + return nil +} + func phoneConsentTarget(message *events.Message) types.JID { if message.Info.SenderAlt.Server == types.HiddenUserServer { return message.Info.SenderAlt.ToNonAD() @@ -824,6 +912,7 @@ func (r *runner) snapshot(completed bool) result { MediaUploadLatency: r.uploadLatencySnapshot(), BusinessAppValidated: r.businessValid.Load(), PhoneConsentValidated: r.phoneConsentValid.Load(), + SecurityCodeValidated: r.securityCodeValid.Load(), } r.metricsMu.Lock() if r.sessionStarted { diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 8cfd39a9a..ce3d92eba 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -32,6 +32,7 @@ func TestLoadConfigWorkload(t *testing.T) { t.Setenv("HISTORY_MESSAGES", "5") t.Setenv("BENCH_BUSINESS_SMOKE", "1") t.Setenv("BENCH_PHONE_CONSENT_SMOKE", "1") + t.Setenv("BENCH_SECURITY_CODE_SMOKE", "1") cfg, err := loadConfig() if err != nil { @@ -52,6 +53,9 @@ func TestLoadConfigWorkload(t *testing.T) { if !cfg.PhoneConsentSmoke { t.Fatal("phone consent smoke validation was not enabled") } + if !cfg.SecurityCodeSmoke { + t.Fatal("security code smoke validation was not enabled") + } } func TestLoadConfigRejectsInvalidWorkload(t *testing.T) { @@ -160,7 +164,7 @@ func TestPhoneConsentSmokeRunsBeforeMeasuredMetrics(t *testing.T) { t.Fatal("workload metrics started before phone consent validation") } return nil - }) { + }, nil) { t.Fatal("phone consent validation failed") } r.startOnce.Do(r.startMetrics) @@ -176,7 +180,7 @@ func TestPhoneConsentFailureDoesNotStartMeasuredMetrics(t *testing.T) { fatalErr: make(chan error, 1), failureReasons: make(map[string]int64), } - if r.beforeBenchmarkMessage(func() error { return errors.New("failed") }) { + if r.beforeBenchmarkMessage(func() error { return errors.New("failed") }, nil) { t.Fatal("failed phone consent validation allowed the workload to start") } if r.startedAt.Load() != 0 { @@ -333,6 +337,36 @@ func TestHandlerUsesRunContextForPhoneConsent(t *testing.T) { } } +func TestOptionalSmokesFinishBeforeMeasuredMetrics(t *testing.T) { + r := &runner{ + cfg: config{ + PhoneConsentSmoke: true, + SecurityCodeSmoke: true, + }, + fatalErr: make(chan error, 1), + failureReasons: make(map[string]int64), + } + var order []string + if !r.beforeBenchmarkMessage( + func() error { + order = append(order, "phone") + return nil + }, + func() error { + if r.startedAt.Load() != 0 { + t.Fatal("workload metrics started before security code validation") + } + order = append(order, "security") + return nil + }, + ) { + t.Fatal("preflight validation failed") + } + if len(order) != 2 || order[0] != "phone" || order[1] != "security" { + t.Fatalf("validation order = %v", order) + } +} + func TestLoadConfigRejectsInvalidMessageProfile(t *testing.T) { t.Setenv("BENCH_MESSAGE_PROFILE", "production") if _, err := loadConfig(); err == nil { diff --git a/benchmark/barback/compose.yaml b/benchmark/barback/compose.yaml index 2256616c9..a4573ab6a 100644 --- a/benchmark/barback/compose.yaml +++ b/benchmark/barback/compose.yaml @@ -115,6 +115,7 @@ services: BENCH_GROUP_SIZE: ${BENCH_GROUP_SIZE:-128} BENCH_BUSINESS_SMOKE: ${BENCH_BUSINESS_SMOKE:-false} BENCH_PHONE_CONSENT_SMOKE: ${BENCH_PHONE_CONSENT_SMOKE:-false} + BENCH_SECURITY_CODE_SMOKE: ${BENCH_SECURITY_CODE_SMOKE:-false} BENCH_MODE: ${BENCH_MODE:-group} BENCH_MESSAGE_PROFILE: ${BENCH_MESSAGE_PROFILE:-text} BENCH_RATE: ${BENCH_RATE:-50} From b84c358f7ea9be654aadab67bbfc9745f3aea0e0 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 19:21:33 +0300 Subject: [PATCH 127/163] Expose stable LID resolution --- lid_resolution_test.go | 4 ++-- message.go | 2 +- send.go | 2 +- user.go | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lid_resolution_test.go b/lid_resolution_test.go index 8505049c3..acb0559af 100644 --- a/lid_resolution_test.go +++ b/lid_resolution_test.go @@ -28,7 +28,7 @@ func TestResolveLIDUsesCachedMapping(t *testing.T) { lids := &cachedLIDStore{pn: pn.ToNonAD(), lid: lid} client := NewClient(&store.Device{LIDs: lids}, waLog.Noop) - resolved, err := client.resolveLID(context.Background(), pn) + resolved, err := client.ResolveLID(context.Background(), pn) if err != nil { t.Fatal(err) } @@ -41,7 +41,7 @@ func TestResolveLIDUsesCachedMapping(t *testing.T) { func TestResolveLIDRejectsNonPhoneJID(t *testing.T) { client := NewClient(&store.Device{LIDs: &store.NoopStore{}}, waLog.Noop) - if _, err := client.resolveLID(context.Background(), types.NewJID("100000011111111", types.HiddenUserServer)); err == nil { + if _, err := client.ResolveLID(context.Background(), types.NewJID("100000011111111", types.HiddenUserServer)); err == nil { t.Fatal("expected non-PN JID to fail") } } diff --git a/message.go b/message.go index 6619099ec..343e41fcc 100644 --- a/message.go +++ b/message.go @@ -364,7 +364,7 @@ func (cli *Client) decryptMessages(ctx context.Context, info *types.MessageInfo, if info.SenderAlt.Server == types.HiddenUserServer { senderEncryptionJID = info.SenderAlt cli.migrateSessionStore(ctx, info.Sender, info.SenderAlt) - } else if lid, err := cli.resolveLID(ctx, info.Sender); err != nil { + } else if lid, err := cli.ResolveLID(ctx, info.Sender); err != nil { cli.Log.Errorf("Failed to resolve LID for %s: %v", info.Sender, err) if cli.SynchronousAck { cli.sendRetryReceipt(ctx, node, info, false) diff --git a/send.go b/send.go index dfe052d73..de7971942 100644 --- a/send.go +++ b/send.go @@ -337,7 +337,7 @@ func (cli *Client) SendMessage(ctx context.Context, to types.JID, message *waE2E } else if to.Server == types.DefaultUserServer && !req.Peer { start := time.Now() var toLID types.JID - toLID, err = cli.resolveLID(ctx, to) + toLID, err = cli.ResolveLID(ctx, to) if err != nil { err = fmt.Errorf("failed to resolve LID for PN %s: %w", to, err) return diff --git a/user.go b/user.go index 748424290..95d919c1a 100644 --- a/user.go +++ b/user.go @@ -397,7 +397,8 @@ func (cli *Client) ResolveUsername(ctx context.Context, username, key string) (t return result, nil } -func (cli *Client) resolveLID(ctx context.Context, phone types.JID) (types.JID, error) { +// ResolveLID returns the stable LID for a phone-number JID. +func (cli *Client) ResolveLID(ctx context.Context, phone types.JID) (types.JID, error) { device := phone.Device phone = phone.ToNonAD() if phone.Server != types.DefaultUserServer { From 31647035198cf99ce8a1bbcc64bbef7df8c82390 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 19:33:38 +0300 Subject: [PATCH 128/163] Avoid redundant self identity fetch --- security_code.go | 29 ++++++++++++++++++++--------- security_code_test.go | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/security_code.go b/security_code.go index ecc797b38..b889285ae 100644 --- a/security_code.go +++ b/security_code.go @@ -81,16 +81,11 @@ func (cli *Client) GetIdentityVerificationCodes(ctx context.Context, userID type if err != nil { return nil, fmt.Errorf("get identity verification devices: %w", err) } - localDevices := make([]types.JID, 0, len(devices)) - remoteDevices := make([]types.JID, 0, len(devices)) - for _, device := range devices { - switch { - case device.User == localLID.User && (device.Server == types.HiddenUserServer || device.Server == types.HostedLIDServer): - localDevices = append(localDevices, device) - case device.User == userID.User && (device.Server == types.HiddenUserServer || device.Server == types.HostedLIDServer): - remoteDevices = append(remoteDevices, device) - } + var currentDevice uint16 + if cli.Store.ID != nil { + currentDevice = cli.Store.ID.Device } + localDevices, remoteDevices := splitIdentityVerificationDevices(devices, localLID, userID, currentDevice) if len(remoteDevices) == 0 { return nil, fmt.Errorf("no devices found for %s", userID) } @@ -129,6 +124,22 @@ func (cli *Client) GetIdentityVerificationCodes(ctx context.Context, userID type return newIdentityVerificationCodes(ctx, local, remote) } +func splitIdentityVerificationDevices(devices []types.JID, localLID, remoteLID types.JID, currentDevice uint16) ([]types.JID, []types.JID) { + localDevices := make([]types.JID, 0, len(devices)) + remoteDevices := make([]types.JID, 0, len(devices)) + for _, device := range devices { + switch { + case device.User == localLID.User && (device.Server == types.HiddenUserServer || device.Server == types.HostedLIDServer): + if device.Device != currentDevice { + localDevices = append(localDevices, device) + } + case device.User == remoteLID.User && (device.Server == types.HiddenUserServer || device.Server == types.HostedLIDServer): + remoteDevices = append(remoteDevices, device) + } + } + return localDevices, remoteDevices +} + func (cli *Client) readIdentityKeys(ctx context.Context, devices []types.JID) ([][32]byte, error) { if cli == nil || cli.Store == nil { return nil, ErrClientIsNil diff --git a/security_code_test.go b/security_code_test.go index 8f2fcef3c..1bce50aef 100644 --- a/security_code_test.go +++ b/security_code_test.go @@ -174,6 +174,24 @@ func TestGetIdentityVerificationCodesRequiresLID(t *testing.T) { } } +func TestSplitIdentityVerificationDevicesExcludesCurrentDevice(t *testing.T) { + local := types.NewJID("100000000000001", types.HiddenUserServer) + remote := types.NewJID("100000000000002", types.HiddenUserServer) + devices := []types.JID{ + types.NewADJID(local.User, types.LIDDomain, 67), + types.NewADJID(local.User, types.LIDDomain, 0), + types.NewADJID(remote.User, types.LIDDomain, 0), + } + + localDevices, remoteDevices := splitIdentityVerificationDevices(devices, local, remote, 67) + if len(localDevices) != 1 || localDevices[0].Device != 0 { + t.Fatalf("local devices = %v, want only device 0", localDevices) + } + if len(remoteDevices) != 1 || remoteDevices[0].Device != 0 { + t.Fatalf("remote devices = %v, want only device 0", remoteDevices) + } +} + func TestReadIdentityKeysUsesOptionalBatchReader(t *testing.T) { devices := []types.JID{ types.NewADJID("100000000000001", types.LIDDomain, 1), From 1175c604a6c5290ce4bb7f64c9f3981c3e4e154f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 19:42:19 +0300 Subject: [PATCH 129/163] Preserve local device zero without registration --- security_code.go | 7 ++++--- security_code_test.go | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/security_code.go b/security_code.go index b889285ae..4eb77e935 100644 --- a/security_code.go +++ b/security_code.go @@ -82,10 +82,11 @@ func (cli *Client) GetIdentityVerificationCodes(ctx context.Context, userID type return nil, fmt.Errorf("get identity verification devices: %w", err) } var currentDevice uint16 + hasCurrentDevice := cli.Store.ID != nil if cli.Store.ID != nil { currentDevice = cli.Store.ID.Device } - localDevices, remoteDevices := splitIdentityVerificationDevices(devices, localLID, userID, currentDevice) + localDevices, remoteDevices := splitIdentityVerificationDevices(devices, localLID, userID, currentDevice, hasCurrentDevice) if len(remoteDevices) == 0 { return nil, fmt.Errorf("no devices found for %s", userID) } @@ -124,13 +125,13 @@ func (cli *Client) GetIdentityVerificationCodes(ctx context.Context, userID type return newIdentityVerificationCodes(ctx, local, remote) } -func splitIdentityVerificationDevices(devices []types.JID, localLID, remoteLID types.JID, currentDevice uint16) ([]types.JID, []types.JID) { +func splitIdentityVerificationDevices(devices []types.JID, localLID, remoteLID types.JID, currentDevice uint16, hasCurrentDevice bool) ([]types.JID, []types.JID) { localDevices := make([]types.JID, 0, len(devices)) remoteDevices := make([]types.JID, 0, len(devices)) for _, device := range devices { switch { case device.User == localLID.User && (device.Server == types.HiddenUserServer || device.Server == types.HostedLIDServer): - if device.Device != currentDevice { + if !hasCurrentDevice || device.Device != currentDevice { localDevices = append(localDevices, device) } case device.User == remoteLID.User && (device.Server == types.HiddenUserServer || device.Server == types.HostedLIDServer): diff --git a/security_code_test.go b/security_code_test.go index 1bce50aef..60776b67b 100644 --- a/security_code_test.go +++ b/security_code_test.go @@ -183,7 +183,7 @@ func TestSplitIdentityVerificationDevicesExcludesCurrentDevice(t *testing.T) { types.NewADJID(remote.User, types.LIDDomain, 0), } - localDevices, remoteDevices := splitIdentityVerificationDevices(devices, local, remote, 67) + localDevices, remoteDevices := splitIdentityVerificationDevices(devices, local, remote, 67, true) if len(localDevices) != 1 || localDevices[0].Device != 0 { t.Fatalf("local devices = %v, want only device 0", localDevices) } @@ -192,6 +192,20 @@ func TestSplitIdentityVerificationDevicesExcludesCurrentDevice(t *testing.T) { } } +func TestSplitIdentityVerificationDevicesKeepsDeviceZeroWithoutCurrentDevice(t *testing.T) { + local := types.NewJID("100000000000001", types.HiddenUserServer) + remote := types.NewJID("100000000000002", types.HiddenUserServer) + devices := []types.JID{ + types.NewADJID(local.User, types.LIDDomain, 0), + types.NewADJID(remote.User, types.LIDDomain, 0), + } + + localDevices, _ := splitIdentityVerificationDevices(devices, local, remote, 0, false) + if len(localDevices) != 1 || localDevices[0].Device != 0 { + t.Fatalf("local devices = %v, want device 0", localDevices) + } +} + func TestReadIdentityKeysUsesOptionalBatchReader(t *testing.T) { devices := []types.JID{ types.NewADJID("100000000000001", types.LIDDomain, 1), From 4845ecbbdea15c8026f32160b53dd5b48cb0826e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:20:30 +0300 Subject: [PATCH 130/163] fix: preserve concurrent identity cache writes --- store/sqlstore/store.go | 16 ++++++++++++++-- store/sqlstore/store_test.go | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index e9b964634..2fce64cba 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -281,12 +281,24 @@ func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (m if err != nil { return nil, err } + s.cacheFetchedIdentities(result, fetched) + return result, nil +} + +func (s *SQLStore) cacheFetchedIdentities(result map[string][32]byte, fetched map[string]identityCacheEntry) { s.identityCacheLock.Lock() + defer s.identityCacheLock.Unlock() for address, entry := range fetched { + if current, ok := s.identityCache[address]; ok { + if current.Present { + result[address] = current.Key + } else { + delete(result, address) + } + continue + } s.setCachedIdentityLocked(address, entry) } - s.identityCacheLock.Unlock() - return result, nil } const ( diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 6998d9255..888f6ea1e 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -152,6 +152,26 @@ func TestIdentityCacheIsBounded(t *testing.T) { } } +func TestCacheFetchedIdentitiesPreservesConcurrentWrites(t *testing.T) { + address := "100000011111111_1:7" + oldKey := [32]byte{1} + newKey := [32]byte{2} + store := &SQLStore{identityCache: map[string]identityCacheEntry{ + address: {Key: newKey, Present: true}, + }} + result := map[string][32]byte{address: oldKey} + store.cacheFetchedIdentities(result, map[string]identityCacheEntry{ + address: {Key: oldKey, Present: true}, + }) + + if got := store.identityCache[address]; !got.Present || got.Key != newKey { + t.Fatalf("identity cache was overwritten with stale query result: %#v", got) + } + if result[address] != newKey { + t.Fatalf("returned identity = %x, want concurrent value %x", result[address], newKey) + } +} + func TestContactCacheIsBounded(t *testing.T) { store := &SQLStore{contactCache: make(map[types.JID]*types.ContactInfo, maxContactCacheEntries)} for i := 0; i < maxContactCacheEntries; i++ { From 9e65efcf0069afaca6b8af03514b7b755bbe2461 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:42:29 +0300 Subject: [PATCH 131/163] fix: harden identity verification state --- security_code.go | 11 +++++++++++ security_code_test.go | 10 ++++++++++ store/sqlstore/store.go | 27 ++++++++++++++++++++++++--- store/sqlstore/store_test.go | 18 ++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/security_code.go b/security_code.go index 4eb77e935..ffdd2f173 100644 --- a/security_code.go +++ b/security_code.go @@ -104,6 +104,8 @@ func (cli *Client) GetIdentityVerificationCodes(ctx context.Context, userID type local := identityVerificationFingerprint{LID: localLID, Keys: localKeys} remote := identityVerificationFingerprint{LID: userID, Keys: remoteKeys} + local.Hosted = hasHostedIdentityDevice(localDevices) + remote.Hosted = hasHostedIdentityDevice(remoteDevices) if cli.Store.ID != nil && cli.Store.ID.Server == types.DefaultUserServer { local.Phone = cli.Store.ID.ToNonAD() } @@ -141,6 +143,12 @@ func splitIdentityVerificationDevices(devices []types.JID, localLID, remoteLID t return localDevices, remoteDevices } +func hasHostedIdentityDevice(devices []types.JID) bool { + return slices.ContainsFunc(devices, func(device types.JID) bool { + return device.Server == types.HostedLIDServer || device.Server == types.HostedServer + }) +} + func (cli *Client) readIdentityKeys(ctx context.Context, devices []types.JID) ([][32]byte, error) { if cli == nil || cli.Store == nil { return nil, ErrClientIsNil @@ -163,6 +171,9 @@ func (cli *Client) readIdentityKeys(ctx context.Context, devices []types.JID) ([ if err != nil { return nil, fmt.Errorf("read identity keys: %w", err) } + if stored == nil { + stored = make(map[string][32]byte, len(addresses)) + } missing := make([]types.JID, 0, len(addresses)-len(stored)) for _, address := range addresses { if _, exists := stored[address]; !exists { diff --git a/security_code_test.go b/security_code_test.go index 60776b67b..5691dbed7 100644 --- a/security_code_test.go +++ b/security_code_test.go @@ -206,6 +206,16 @@ func TestSplitIdentityVerificationDevicesKeepsDeviceZeroWithoutCurrentDevice(t * } } +func TestIdentityVerificationFingerprintMarksHostedDevices(t *testing.T) { + devices := []types.JID{ + types.NewADJID("100000000000002", types.LIDDomain, 1), + types.NewADJID("100000000000002", types.HostedLIDDomain, 2), + } + if !hasHostedIdentityDevice(devices) { + t.Fatal("hosted identity device was labeled E2EE") + } +} + func TestReadIdentityKeysUsesOptionalBatchReader(t *testing.T) { devices := []types.JID{ types.NewADJID("100000000000001", types.LIDDomain, 1), diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 2fce64cba..291337ac8 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -169,7 +169,7 @@ func (s *SQLStore) DeleteAllIdentities(ctx context.Context, phone string) error if err == nil { for address := range s.identityCache { if strings.HasPrefix(address, phone+":") { - delete(s.identityCache, address) + s.setCachedIdentityLocked(address, identityCacheEntry{}) } } } @@ -181,7 +181,7 @@ func (s *SQLStore) DeleteIdentity(ctx context.Context, address string) error { defer s.identityCacheLock.Unlock() _, err := s.db.Exec(ctx, deleteIdentityQuery, s.JID, address) if err == nil { - delete(s.identityCache, address) + s.setCachedIdentityLocked(address, identityCacheEntry{}) } return err } @@ -264,6 +264,23 @@ func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (m return result, nil } + s.identityCacheLock.Lock() + defer s.identityCacheLock.Unlock() + stillMissing := missing[:0] + for _, address := range missing { + if cached, ok := s.identityCache[address]; ok { + if cached.Present { + result[address] = cached.Key + } + } else { + stillMissing = append(stillMissing, address) + } + } + missing = stillMissing + if len(missing) == 0 { + return result, nil + } + rows, err := s.queryManyIdentities(ctx, missing) fetched := make(map[string]identityCacheEntry, len(missing)) for _, address := range missing { @@ -281,13 +298,17 @@ func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (m if err != nil { return nil, err } - s.cacheFetchedIdentities(result, fetched) + s.cacheFetchedIdentitiesLocked(result, fetched) return result, nil } func (s *SQLStore) cacheFetchedIdentities(result map[string][32]byte, fetched map[string]identityCacheEntry) { s.identityCacheLock.Lock() defer s.identityCacheLock.Unlock() + s.cacheFetchedIdentitiesLocked(result, fetched) +} + +func (s *SQLStore) cacheFetchedIdentitiesLocked(result map[string][32]byte, fetched map[string]identityCacheEntry) { for address, entry := range fetched { if current, ok := s.identityCache[address]; ok { if current.Present { diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 888f6ea1e..a01fffad9 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -172,6 +172,24 @@ func TestCacheFetchedIdentitiesPreservesConcurrentWrites(t *testing.T) { } } +func TestDeleteIdentityLeavesNegativeCacheEntry(t *testing.T) { + state := &contactUsernameRaceDB{entered: make(chan struct{}), release: make(chan struct{})} + close(state.release) + db := sql.OpenDB(&contactUsernameRaceConnector{state: state}) + t.Cleanup(func() { _ = db.Close() }) + address := "100000011111111_1:7" + sqlStore := NewSQLStore(NewWithDB(db, "postgres", nil), types.NewJID("15550000000", types.DefaultUserServer)) + sqlStore.identityCache = map[string]identityCacheEntry{address: {Key: [32]byte{1}, Present: true}} + + if err := sqlStore.DeleteIdentity(context.Background(), address); err != nil { + t.Fatal(err) + } + entry, exists := sqlStore.identityCache[address] + if !exists || entry.Present { + t.Fatalf("identity deletion did not leave a negative cache entry: %#v", entry) + } +} + func TestContactCacheIsBounded(t *testing.T) { store := &SQLStore{contactCache: make(map[types.JID]*types.ContactInfo, maxContactCacheEntries)} for i := 0; i < maxContactCacheEntries; i++ { From 478d83f4df25eccc24b12ac661c72e3546527e36 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:03:30 +0300 Subject: [PATCH 132/163] test: serialize security code smoke failures --- benchmark/barback/cmd/bench/main_test.go | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index ce3d92eba..41d1490f7 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -367,6 +367,46 @@ func TestOptionalSmokesFinishBeforeMeasuredMetrics(t *testing.T) { } } +func TestSecurityCodeFailureIsSharedAcrossWorkers(t *testing.T) { + r := &runner{ + fatalErr: make(chan error, 1), + failureReasons: make(map[string]int64), + } + sentinel := errors.New("security code failed") + entered := make(chan struct{}) + release := make(chan struct{}) + var calls atomic.Int64 + validate := func() error { + if calls.Add(1) == 1 { + close(entered) + } + <-release + return sentinel + } + + var workers sync.WaitGroup + errs := make(chan error, 2) + for range 2 { + workers.Add(1) + go func() { + defer workers.Done() + errs <- r.runSecurityCodeSmoke(validate) + }() + } + <-entered + close(release) + workers.Wait() + close(errs) + for err := range errs { + if !errors.Is(err, sentinel) { + t.Fatalf("worker error = %v, want %v", err, sentinel) + } + } + if calls.Load() != 1 { + t.Fatalf("validation calls = %d, want 1", calls.Load()) + } +} + func TestLoadConfigRejectsInvalidMessageProfile(t *testing.T) { t.Setenv("BENCH_MESSAGE_PROFILE", "production") if _, err := loadConfig(); err == nil { From 4f1e1463019d04299dd1614dc868879c757ab5f3 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:23:15 +0300 Subject: [PATCH 133/163] fix: tolerate oversized identity reader results --- security_code.go | 2 +- security_code_test.go | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/security_code.go b/security_code.go index ffdd2f173..c4e59282a 100644 --- a/security_code.go +++ b/security_code.go @@ -174,7 +174,7 @@ func (cli *Client) readIdentityKeys(ctx context.Context, devices []types.JID) ([ if stored == nil { stored = make(map[string][32]byte, len(addresses)) } - missing := make([]types.JID, 0, len(addresses)-len(stored)) + missing := make([]types.JID, 0, len(addresses)) for _, address := range addresses { if _, exists := stored[address]; !exists { missing = append(missing, deviceByAddress[address]) diff --git a/security_code_test.go b/security_code_test.go index 5691dbed7..44e9ed5da 100644 --- a/security_code_test.go +++ b/security_code_test.go @@ -16,7 +16,8 @@ import ( ) type identityReaderStore struct { - keys map[string][32]byte + keys map[string][32]byte + includeAll bool } func (*identityReaderStore) PutIdentity(context.Context, string, [32]byte) error { return nil } @@ -27,6 +28,12 @@ func (*identityReaderStore) IsTrustedIdentity(context.Context, string, [32]byte) } func (irs *identityReaderStore) GetManyIdentities(_ context.Context, addresses []string) (map[string][32]byte, error) { result := make(map[string][32]byte, len(addresses)) + if irs.includeAll { + for address, key := range irs.keys { + result[address] = key + } + return result, nil + } for _, address := range addresses { if key, ok := irs.keys[address]; ok { result[address] = key @@ -35,6 +42,23 @@ func (irs *identityReaderStore) GetManyIdentities(_ context.Context, addresses [ return result, nil } +func TestReadIdentityKeysIgnoresUnrequestedReaderEntries(t *testing.T) { + device := types.NewADJID("100000000000001", types.LIDDomain, 1) + want := [32]byte{1} + identities := &identityReaderStore{includeAll: true, keys: map[string][32]byte{ + device.SignalAddress().String(): want, + "unrequested:1": {2}, + }} + client := &Client{Store: &store.Device{Identities: identities}} + keys, err := client.readIdentityKeys(context.Background(), []types.JID{device}) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0] != want { + t.Fatalf("identity keys = %x, want %x", keys, want) + } +} + var _ store.IdentityKeyReader = (*identityReaderStore)(nil) func TestGenerateNumericSecurityCodeMatchesWhatsAppWebV4(t *testing.T) { From 4c7ff56e2de1cdddebaa93a4021501f2a3567426 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:46:41 +0300 Subject: [PATCH 134/163] fix: update LID-only device caches --- notification.go | 50 ++++++++++++++++++++++--------------- notification_device_test.go | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 notification_device_test.go diff --git a/notification.go b/notification.go index 49eb69d42..2b1ad14f4 100644 --- a/notification.go +++ b/notification.go @@ -122,17 +122,18 @@ func (cli *Client) handleDeviceNotification(ctx context.Context, node *waBinary. if fromLID != nil { cli.StoreLIDPNMapping(ctx, *fromLID, from) } - cached, ok := cli.userDevicesCache[from] - if !ok { - cli.Log.Debugf("No device list cached for %s, ignoring device list notification", from) - return - } + cached, hasCachedPN := cli.userDevicesCache[from] var cachedLID deviceCache var cachedLIDHash string + var hasCachedLID bool if fromLID != nil { - cachedLID = cli.userDevicesCache[*fromLID] + cachedLID, hasCachedLID = cli.userDevicesCache[*fromLID] cachedLIDHash = participantListHashV2(cachedLID.devices) } + if !hasCachedPN && !hasCachedLID { + cli.Log.Debugf("No device list cached for %s, ignoring device list notification", from) + return + } cachedParticipantHash := participantListHashV2(cached.devices) for _, child := range node.GetChildren() { cag := child.AttrGetter() @@ -143,15 +144,19 @@ func (cli *Client) handleDeviceNotification(ctx context.Context, node *waBinary. changedDeviceLID := deviceChild.AttrGetter().OptionalJID("lid") switch child.Tag { case "add": - cached.devices = append(cached.devices, changedDeviceJID) - if changedDeviceLID != nil { + if hasCachedPN { + cached.devices = append(cached.devices, changedDeviceJID) + } + if hasCachedLID && changedDeviceLID != nil { cachedLID.devices = append(cachedLID.devices, *changedDeviceLID) } case "remove": - cached.devices = slices.DeleteFunc(cached.devices, func(existing types.JID) bool { - return existing == changedDeviceJID - }) - if changedDeviceLID != nil { + if hasCachedPN { + cached.devices = slices.DeleteFunc(cached.devices, func(existing types.JID) bool { + return existing == changedDeviceJID + }) + } + if hasCachedLID && changedDeviceLID != nil { cachedLID.devices = slices.DeleteFunc(cachedLID.devices, func(existing types.JID) bool { return existing == *changedDeviceLID }) @@ -160,20 +165,25 @@ func (cli *Client) handleDeviceNotification(ctx context.Context, node *waBinary. // Exact meaning of "update" is unknown, clear device list cache to be safe cli.Log.Debugf("%s's device list updated, dropping cached devices", from) delete(cli.userDevicesCache, from) + if fromLID != nil { + delete(cli.userDevicesCache, *fromLID) + } continue default: cli.Log.Debugf("Unknown device list change tag %s", child.Tag) continue } - newParticipantHash := participantListHashV2(cached.devices) - if newParticipantHash == deviceHash { - cli.Log.Debugf("%s's device list hash changed from %s to %s (%s). New hash matches", from, cachedParticipantHash, deviceHash, child.Tag) - putBoundedCache(ensureMap(&cli.userDevicesCache), from, cached, maxUserDeviceCacheEntries) - } else { - cli.Log.Warnf("%s's device list hash changed from %s to %s (%s). New hash doesn't match (%s)", from, cachedParticipantHash, deviceHash, child.Tag, newParticipantHash) - delete(cli.userDevicesCache, from) + if hasCachedPN { + newParticipantHash := participantListHashV2(cached.devices) + if newParticipantHash == deviceHash { + cli.Log.Debugf("%s's device list hash changed from %s to %s (%s). New hash matches", from, cachedParticipantHash, deviceHash, child.Tag) + putBoundedCache(ensureMap(&cli.userDevicesCache), from, cached, maxUserDeviceCacheEntries) + } else { + cli.Log.Warnf("%s's device list hash changed from %s to %s (%s). New hash doesn't match (%s)", from, cachedParticipantHash, deviceHash, child.Tag, newParticipantHash) + delete(cli.userDevicesCache, from) + } } - if fromLID != nil && changedDeviceLID != nil && deviceLIDHash != "" { + if fromLID != nil && hasCachedLID && changedDeviceLID != nil && deviceLIDHash != "" { newLIDParticipantHash := participantListHashV2(cachedLID.devices) if newLIDParticipantHash == deviceLIDHash { cli.Log.Debugf("%s's device list hash changed from %s to %s (%s). New hash matches", fromLID, cachedLIDHash, deviceLIDHash, child.Tag) diff --git a/notification_device_test.go b/notification_device_test.go new file mode 100644 index 000000000..dd7afef74 --- /dev/null +++ b/notification_device_test.go @@ -0,0 +1,45 @@ +package whatsmeow + +import ( + "context" + "testing" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" + waLog "go.mau.fi/whatsmeow/util/log" +) + +func TestDeviceNotificationUpdatesLIDOnlyCache(t *testing.T) { + pn := types.NewJID("15551234567", types.DefaultUserServer) + lid := types.NewJID("123456789012345", types.HiddenUserServer) + existingLID := types.NewADJID(lid.User, 0, 1) + addedLID := types.NewADJID(lid.User, 0, 2) + addedPN := types.NewADJID(pn.User, 0, 2) + wantDevices := []types.JID{existingLID, addedLID} + cli := &Client{ + Store: store.NoopDevice, + Log: waLog.Noop, + userDevicesCache: map[types.JID]deviceCache{ + lid: {devices: []types.JID{existingLID}, dhash: participantListHashV2([]types.JID{existingLID})}, + }, + } + + cli.handleDeviceNotification(context.Background(), &waBinary.Node{ + Tag: "notification", + Attrs: waBinary.Attrs{"from": pn, "lid": lid}, + Content: []waBinary.Node{{ + Tag: "add", + Attrs: waBinary.Attrs{ + "device_hash": "unused", + "device_lid_hash": participantListHashV2(wantDevices), + }, + Content: []waBinary.Node{{Tag: "device", Attrs: waBinary.Attrs{"jid": addedPN, "lid": addedLID}}}, + }}, + }) + + got := cli.userDevicesCache[lid].devices + if len(got) != 2 || got[0] != existingLID || got[1] != addedLID { + t.Fatalf("LID cache was not updated: %#v", got) + } +} From 344f6a881cb621b4ffb01f48a1c7e0b729741ddd Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:24:23 +0300 Subject: [PATCH 135/163] fix: invalidate mapped Signal identities --- notification.go | 48 +++++++++++++++--- notification_identity_test.go | 96 +++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 notification_identity_test.go diff --git a/notification.go b/notification.go index 2b1ad14f4..c9e9615e8 100644 --- a/notification.go +++ b/notification.go @@ -39,14 +39,7 @@ func (cli *Client) handleEncryptNotification(ctx context.Context, node *waBinary } } else if _, ok := node.GetOptionalChildByTag("identity"); ok { cli.Log.Debugf("Got identity change for %s: %s, deleting all identities/sessions for that number", from, node) - err := cli.Store.Identities.DeleteAllIdentities(ctx, from.User) - if err != nil { - cli.Log.Warnf("Failed to delete all identities of %s from store after identity change: %v", from, err) - } - err = cli.Store.Sessions.DeleteAllSessions(ctx, from.User) - if err != nil { - cli.Log.Warnf("Failed to delete all sessions of %s from store after identity change: %v", from, err) - } + cli.deleteIdentityChangeState(ctx, from) ts := node.AttrGetter().UnixTime("t") storageLID := cli.resolveTCTokenStorageLID(ctx, from) pt, err := cli.Store.PrivacyTokens.GetPrivacyToken(ctx, storageLID) @@ -70,6 +63,45 @@ func (cli *Client) handleEncryptNotification(ctx context.Context, node *waBinary } } +func (cli *Client) deleteIdentityChangeState(ctx context.Context, from types.JID) { + users := identityChangeSignalUsers(from) + if cli.Store.LIDs != nil { + var alternate types.JID + var err error + switch from.Server { + case types.DefaultUserServer, types.HostedServer: + alternate, err = cli.Store.LIDs.GetLIDForPN(ctx, types.NewJID(from.User, types.DefaultUserServer)) + case types.HiddenUserServer, types.HostedLIDServer: + alternate, err = cli.Store.LIDs.GetPNForLID(ctx, types.NewJID(from.User, types.HiddenUserServer)) + } + if err != nil { + cli.Log.Warnf("Failed to resolve alternate JID for identity change from %s: %v", from, err) + } else if !alternate.IsEmpty() { + users = append(users, identityChangeSignalUsers(alternate)...) + } + } + users = slices.Compact(users) + for _, user := range users { + if err := cli.Store.Identities.DeleteAllIdentities(ctx, user); err != nil { + cli.Log.Warnf("Failed to delete identities for %s after identity change from %s: %v", user, from, err) + } + if err := cli.Store.Sessions.DeleteAllSessions(ctx, user); err != nil { + cli.Log.Warnf("Failed to delete sessions for %s after identity change from %s: %v", user, from, err) + } + } +} + +func identityChangeSignalUsers(jid types.JID) []string { + switch jid.Server { + case types.DefaultUserServer, types.HostedServer: + return []string{jid.User, jid.User + "_128"} + case types.HiddenUserServer, types.HostedLIDServer: + return []string{jid.User + "_1", jid.User + "_129"} + default: + return []string{jid.SignalAddressUser()} + } +} + func (cli *Client) handleAppStateNotification(ctx context.Context, node *waBinary.Node) { for _, collection := range node.GetChildrenByTag("collection") { ag := collection.AttrGetter() diff --git a/notification_identity_test.go b/notification_identity_test.go new file mode 100644 index 000000000..04535a8ec --- /dev/null +++ b/notification_identity_test.go @@ -0,0 +1,96 @@ +package whatsmeow + +import ( + "context" + "slices" + "testing" + + waBinary "go.mau.fi/whatsmeow/binary" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/types" + waLog "go.mau.fi/whatsmeow/util/log" +) + +type identityChangeStore struct { + store.NoopStore + lid types.JID + pn types.JID + identityDeletes []string + sessionDeletes []string +} + +func (s *identityChangeStore) DeleteAllIdentities(_ context.Context, user string) error { + s.identityDeletes = append(s.identityDeletes, user) + return nil +} + +func (s *identityChangeStore) DeleteAllSessions(_ context.Context, user string) error { + s.sessionDeletes = append(s.sessionDeletes, user) + return nil +} + +func (s *identityChangeStore) GetLIDForPN(_ context.Context, pn types.JID) (types.JID, error) { + if pn.User == s.pn.User { + return s.lid, nil + } + return types.EmptyJID, nil +} + +func (s *identityChangeStore) GetPNForLID(_ context.Context, lid types.JID) (types.JID, error) { + if lid.User == s.lid.User { + return s.pn, nil + } + return types.EmptyJID, nil +} + +func TestIdentityChangeDeletesPNAndLIDSignalState(t *testing.T) { + pn := types.NewJID("15551234567", types.DefaultUserServer) + lid := types.NewJID("123456789012345", types.HiddenUserServer) + recorder := &identityChangeStore{pn: pn, lid: lid} + client := NewClient(&store.Device{ + Identities: recorder, + Sessions: recorder, + LIDs: recorder, + PrivacyTokens: recorder, + }, waLog.Noop) + + client.handleEncryptNotification(context.Background(), &waBinary.Node{ + Tag: "notification", + Attrs: waBinary.Attrs{"from": pn}, + Content: []waBinary.Node{{Tag: "identity"}}, + }) + + want := []string{pn.User, pn.User + "_128", lid.User + "_1", lid.User + "_129"} + if !slices.Equal(recorder.identityDeletes, want) { + t.Fatalf("identity deletes = %v, want %v", recorder.identityDeletes, want) + } + if !slices.Equal(recorder.sessionDeletes, want) { + t.Fatalf("session deletes = %v, want %v", recorder.sessionDeletes, want) + } +} + +func TestIdentityChangeFromLIDDeletesMappedPNState(t *testing.T) { + pn := types.NewJID("15551234567", types.DefaultUserServer) + lid := types.NewJID("123456789012345", types.HiddenUserServer) + recorder := &identityChangeStore{pn: pn, lid: lid} + client := NewClient(&store.Device{ + Identities: recorder, + Sessions: recorder, + LIDs: recorder, + PrivacyTokens: recorder, + }, waLog.Noop) + + client.handleEncryptNotification(context.Background(), &waBinary.Node{ + Tag: "notification", + Attrs: waBinary.Attrs{"from": lid}, + Content: []waBinary.Node{{Tag: "identity"}}, + }) + + want := []string{lid.User + "_1", lid.User + "_129", pn.User, pn.User + "_128"} + if !slices.Equal(recorder.identityDeletes, want) { + t.Fatalf("identity deletes = %v, want %v", recorder.identityDeletes, want) + } + if !slices.Equal(recorder.sessionDeletes, want) { + t.Fatalf("session deletes = %v, want %v", recorder.sessionDeletes, want) + } +} From 57230a6e6802c35e859e314c8977092020b44bdf Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:29:00 +0300 Subject: [PATCH 136/163] fix: isolate candidate benchmark smoke --- benchmark/barback/cmd/bench/main.go | 33 ------------- .../barback/cmd/bench/security_code_smoke.go | 47 +++++++++++++++++++ .../cmd/bench/security_code_smoke_legacy.go | 15 ++++++ 3 files changed, 62 insertions(+), 33 deletions(-) create mode 100644 benchmark/barback/cmd/bench/security_code_smoke.go create mode 100644 benchmark/barback/cmd/bench/security_code_smoke_legacy.go diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 91932a880..310ab0cc1 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -30,7 +30,6 @@ import ( "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/proto/waFingerprint" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" @@ -755,38 +754,6 @@ func (r *runner) reportFatal(err error) { } } -func validateIdentityVerificationCodes(ctx context.Context, client *whatsmeow.Client, userID types.JID) error { - if userID.Server != types.HiddenUserServer { - return whatsmeow.ErrIdentityVerificationRequiresLID - } - codes, err := client.GetIdentityVerificationCodes(ctx, userID) - if err != nil { - return fmt.Errorf("generate identity verification codes: %w", err) - } - if codes.UserID != userID || len(codes.NumericCode) != 60 { - return errors.New("identity verification result has an invalid LID or numeric code") - } - for _, digit := range codes.NumericCode { - if digit < '0' || digit > '9' { - return errors.New("identity verification numeric code contains non-digits") - } - } - var display, verification waFingerprint.CombinedFingerprint - if err = proto.Unmarshal(codes.DisplayQRCode, &display); err != nil { - return fmt.Errorf("decode display QR: %w", err) - } - if err = proto.Unmarshal(codes.VerificationQRCode, &verification); err != nil { - return fmt.Errorf("decode verification QR: %w", err) - } - if len(display.GetLocalFingerprint().GetPublicKey()) != 0 || len(display.GetRemoteFingerprint().GetPublicKey()) != 0 { - return errors.New("display QR exposed unhashed identity keys") - } - if len(verification.GetLocalFingerprint().GetPublicKey()) == 0 || len(verification.GetRemoteFingerprint().GetPublicKey()) == 0 { - return errors.New("verification QR omitted identity keys") - } - return nil -} - func phoneConsentTarget(message *events.Message) types.JID { if message.Info.SenderAlt.Server == types.HiddenUserServer { return message.Info.SenderAlt.ToNonAD() diff --git a/benchmark/barback/cmd/bench/security_code_smoke.go b/benchmark/barback/cmd/bench/security_code_smoke.go new file mode 100644 index 000000000..35398f357 --- /dev/null +++ b/benchmark/barback/cmd/bench/security_code_smoke.go @@ -0,0 +1,47 @@ +//go:build !benchmark_legacy + +package main + +import ( + "context" + "errors" + "fmt" + + "google.golang.org/protobuf/proto" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waFingerprint" + "go.mau.fi/whatsmeow/types" +) + +func validateIdentityVerificationCodes(ctx context.Context, client *whatsmeow.Client, userID types.JID) error { + if userID.Server != types.HiddenUserServer { + return whatsmeow.ErrIdentityVerificationRequiresLID + } + codes, err := client.GetIdentityVerificationCodes(ctx, userID) + if err != nil { + return fmt.Errorf("generate identity verification codes: %w", err) + } + if codes.UserID != userID || len(codes.NumericCode) != 60 { + return errors.New("identity verification result has an invalid LID or numeric code") + } + for _, digit := range codes.NumericCode { + if digit < '0' || digit > '9' { + return errors.New("identity verification numeric code contains non-digits") + } + } + var display, verification waFingerprint.CombinedFingerprint + if err = proto.Unmarshal(codes.DisplayQRCode, &display); err != nil { + return fmt.Errorf("decode display QR: %w", err) + } + if err = proto.Unmarshal(codes.VerificationQRCode, &verification); err != nil { + return fmt.Errorf("decode verification QR: %w", err) + } + if len(display.GetLocalFingerprint().GetPublicKey()) != 0 || len(display.GetRemoteFingerprint().GetPublicKey()) != 0 { + return errors.New("display QR exposed unhashed identity keys") + } + if len(verification.GetLocalFingerprint().GetPublicKey()) == 0 || len(verification.GetRemoteFingerprint().GetPublicKey()) == 0 { + return errors.New("verification QR omitted identity keys") + } + return nil +} diff --git a/benchmark/barback/cmd/bench/security_code_smoke_legacy.go b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go new file mode 100644 index 000000000..a3a1197ac --- /dev/null +++ b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go @@ -0,0 +1,15 @@ +//go:build benchmark_legacy + +package main + +import ( + "context" + "fmt" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" +) + +func validateIdentityVerificationCodes(context.Context, *whatsmeow.Client, types.JID) error { + return fmt.Errorf("identity verification validation requires HyperMeow") +} From 406ad06ccd25be2692cb626fd30ad97c4fdb2432 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:39:52 +0300 Subject: [PATCH 137/163] fix(bench): skip security smoke on legacy baselines --- .../cmd/bench/security_code_smoke_legacy.go | 3 +-- .../cmd/bench/security_code_smoke_legacy_test.go | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go diff --git a/benchmark/barback/cmd/bench/security_code_smoke_legacy.go b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go index a3a1197ac..0fbd6de63 100644 --- a/benchmark/barback/cmd/bench/security_code_smoke_legacy.go +++ b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go @@ -4,12 +4,11 @@ package main import ( "context" - "fmt" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" ) func validateIdentityVerificationCodes(context.Context, *whatsmeow.Client, types.JID) error { - return fmt.Errorf("identity verification validation requires HyperMeow") + return nil } diff --git a/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go b/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go new file mode 100644 index 000000000..433d146d0 --- /dev/null +++ b/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go @@ -0,0 +1,16 @@ +//go:build benchmark_legacy + +package main + +import ( + "context" + "testing" + + "go.mau.fi/whatsmeow/types" +) + +func TestLegacySecurityCodeValidationIsSkipped(t *testing.T) { + if err := validateIdentityVerificationCodes(context.Background(), nil, types.EmptyJID); err != nil { + t.Fatalf("legacy baseline rejected security-code smoke validation: %v", err) + } +} From 3d3ba94c19e71edf0ebf0017acbd99475137dff5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 05:45:30 +0300 Subject: [PATCH 138/163] fix(bench): scope security smoke to candidate --- benchmark/barback/run-comparison-matrix.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/benchmark/barback/run-comparison-matrix.sh b/benchmark/barback/run-comparison-matrix.sh index 8c37b9cdf..775a628be 100755 --- a/benchmark/barback/run-comparison-matrix.sh +++ b/benchmark/barback/run-comparison-matrix.sh @@ -66,15 +66,17 @@ fi run_revision() { local name=$1 context=$2 revision=$3 repeat=$4 scenario=$5 variant=$1 local build_tags= + local security_code_smoke=${BENCH_SECURITY_CODE_SMOKE:-false} if [[ $name != hypermeow ]]; then build_tags=benchmark_legacy + security_code_smoke=false else build_tags=$candidate_build_tags fi if ((repeats > 1)); then variant="${name}-r${repeat}" fi - LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="$variant" BENCH_BUILD_TAGS="$build_tags" ./run-system-matrix.sh "$scenario" + LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="$variant" BENCH_BUILD_TAGS="$build_tags" BENCH_SECURITY_CODE_SMOKE="$security_code_smoke" ./run-system-matrix.sh "$scenario" } for ((repeat = repeat_start; repeat <= repeats; repeat++)); do From 25aa7b13b153be646183e188f77d8facbfd910e2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 06:01:16 +0300 Subject: [PATCH 139/163] fix(bench): gate security smoke on build tags --- benchmark/barback/comparison-matrix-env.sh | 10 ++++++ benchmark/barback/comparison_matrix_test.go | 36 +++++++++++++++++++++ benchmark/barback/run-comparison-matrix.sh | 5 +-- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 benchmark/barback/comparison-matrix-env.sh create mode 100644 benchmark/barback/comparison_matrix_test.go diff --git a/benchmark/barback/comparison-matrix-env.sh b/benchmark/barback/comparison-matrix-env.sh new file mode 100644 index 000000000..161edeb54 --- /dev/null +++ b/benchmark/barback/comparison-matrix-env.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +comparison_security_code_smoke() { + local name=$1 build_tags=$2 requested=$3 + if [[ $name != hypermeow || $build_tags =~ (^|[[:space:],])benchmark_legacy($|[[:space:],]) ]]; then + printf 'false\n' + else + printf '%s\n' "$requested" + fi +} diff --git a/benchmark/barback/comparison_matrix_test.go b/benchmark/barback/comparison_matrix_test.go new file mode 100644 index 000000000..04ee6ac3a --- /dev/null +++ b/benchmark/barback/comparison_matrix_test.go @@ -0,0 +1,36 @@ +package barback_test + +import ( + "os/exec" + "strings" + "testing" +) + +func TestComparisonSecurityCodeSmoke(t *testing.T) { + tests := []struct { + name string + variant string + buildTags string + requested string + want string + }{ + {name: "candidate", variant: "hypermeow", requested: "true", want: "true"}, + {name: "legacy candidate", variant: "hypermeow", buildTags: "benchmark_legacy", requested: "true", want: "false"}, + {name: "legacy candidate among tags", variant: "hypermeow", buildTags: "integration,benchmark_legacy", requested: "true", want: "false"}, + {name: "upstream baseline", variant: "whatsmeow", buildTags: "benchmark_legacy", requested: "true", want: "false"}, + {name: "disabled", variant: "hypermeow", requested: "false", want: "false"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command := `. ./comparison-matrix-env.sh; comparison_security_code_smoke "$1" "$2" "$3"` + output, err := exec.Command("bash", "-c", command, "bash", test.variant, test.buildTags, test.requested).CombinedOutput() + if err != nil { + t.Fatalf("evaluate security-code smoke: %v: %s", err, output) + } + if got := strings.TrimSpace(string(output)); got != test.want { + t.Fatalf("got %q, want %q", got, test.want) + } + }) + } +} diff --git a/benchmark/barback/run-comparison-matrix.sh b/benchmark/barback/run-comparison-matrix.sh index 775a628be..463864616 100755 --- a/benchmark/barback/run-comparison-matrix.sh +++ b/benchmark/barback/run-comparison-matrix.sh @@ -4,6 +4,7 @@ set -euo pipefail benchmark_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_dir=$(CDPATH= cd -- "$benchmark_dir/../.." && pwd) temp_dir=$(mktemp -d) +. "$benchmark_dir/comparison-matrix-env.sh" cleanup() { rm -rf -- "$temp_dir" @@ -66,13 +67,13 @@ fi run_revision() { local name=$1 context=$2 revision=$3 repeat=$4 scenario=$5 variant=$1 local build_tags= - local security_code_smoke=${BENCH_SECURITY_CODE_SMOKE:-false} if [[ $name != hypermeow ]]; then build_tags=benchmark_legacy - security_code_smoke=false else build_tags=$candidate_build_tags fi + local security_code_smoke + security_code_smoke=$(comparison_security_code_smoke "$name" "$build_tags" "${BENCH_SECURITY_CODE_SMOKE:-false}") if ((repeats > 1)); then variant="${name}-r${repeat}" fi From 9610e7870994a3a4149cae0988c2f633ad719657 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 14:04:06 +0300 Subject: [PATCH 140/163] fix: isolate identity verification state --- benchmark/barback/README.md | 7 ++ benchmark/barback/cmd/bench/main.go | 30 ++++++- benchmark/barback/cmd/bench/main_test.go | 24 ++++++ benchmark/barback/compose.yaml | 1 + benchmark/barback/run-comparison-matrix.sh | 5 +- notification.go | 8 ++ notification_device_test.go | 49 +++++++++++ security_code.go | 7 +- security_code_test.go | 16 ++++ store/noop.go | 4 + store/sqlstore/store.go | 39 ++++++++- store/sqlstore/store_test.go | 99 ++++++++++++++++++++++ store/store.go | 1 + 13 files changed, 277 insertions(+), 13 deletions(-) diff --git a/benchmark/barback/README.md b/benchmark/barback/README.md index b1e381769..0d0b260eb 100644 --- a/benchmark/barback/README.md +++ b/benchmark/barback/README.md @@ -83,3 +83,10 @@ Set `BENCH_PHONE_CONSENT_SMOKE=true BARBACK_CAPTURE_MESSAGES=10` to send a request-phone-number message and a share-phone-number protocol message through Signal, then verify both decrypted protobufs in Barback's bounded capture buffer. This uses only the synthetic browser and fake phone identities. + +Set `BENCH_SECURITY_CODE_SMOKE=true` on `run-comparison-matrix.sh` to validate +LID identity verification codes. The driver runs that check in a separate +smoke-only stack, removes its PostgreSQL volume, and then starts the measured +candidate from cold state. Direct Compose runs must also set +`BENCH_SMOKE_ONLY=true`; combining this validation with a measured workload is +rejected because it would warm the candidate's device and identity caches. diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 310ab0cc1..7da923ca9 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -52,6 +52,7 @@ type config struct { BusinessSmoke bool PhoneConsentSmoke bool SecurityCodeSmoke bool + SmokeOnly bool Total int64 Timeout time.Duration Workload workloadConfig @@ -136,6 +137,7 @@ type result struct { BusinessAppValidated bool `json:"business_app_validated"` PhoneConsentValidated bool `json:"phone_consent_validated"` SecurityCodeValidated bool `json:"security_code_validated"` + SmokeOnly bool `json:"smoke_only"` } type runner struct { @@ -300,6 +302,16 @@ func loadConfig() (config, error) { if err != nil { return config{}, fmt.Errorf("invalid BENCH_SECURITY_CODE_SMOKE") } + smokeOnly, err := strconv.ParseBool(env("BENCH_SMOKE_ONLY", "false")) + if err != nil { + return config{}, fmt.Errorf("invalid BENCH_SMOKE_ONLY") + } + if securityCodeSmoke && !smokeOnly { + return config{}, errors.New("BENCH_SECURITY_CODE_SMOKE requires BENCH_SMOKE_ONLY=true") + } + if smokeOnly && !phoneConsentSmoke && !securityCodeSmoke { + return config{}, errors.New("BENCH_SMOKE_ONLY requires a preflight smoke") + } return config{ DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@postgres:5432/hypermeow?sslmode=disable"), BarbackURL: env("BARBACK_URL", "http://barback:8080"), @@ -312,6 +324,7 @@ func loadConfig() (config, error) { BusinessSmoke: businessSmoke, PhoneConsentSmoke: phoneConsentSmoke, SecurityCodeSmoke: securityCodeSmoke, + SmokeOnly: smokeOnly, Total: total, Timeout: timeout, Workload: workloadConfig{ @@ -520,6 +533,10 @@ func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeo }) { return } + if r.cfg.SmokeOnly { + r.doneOnce.Do(func() { close(r.done) }) + return + } r.startOnce.Do(r.startMetrics) r.received.Add(1) jobs := r.jobs[jobShard(event.Info.Chat, len(r.jobs))] @@ -736,7 +753,6 @@ func (r *runner) runSecurityCodeSmoke(validate func() error) error { r.securityCodeValid.Store(true) return } - r.failed.Add(1) r.recordFailure(r.securityCodeErr) fmt.Fprintf(os.Stderr, "validate identity verification code: %v\n", r.securityCodeErr) r.reportFatal(fmt.Errorf("validate identity verification code: %w", r.securityCodeErr)) @@ -855,12 +871,19 @@ func (r *runner) snapshot(completed bool) result { } } runtimeNow := runtimeSnapshot() + targetMessages := r.cfg.Total + workloadCompleted := r.sent.Load() == r.cfg.Total + if r.cfg.SmokeOnly { + targetMessages = 0 + workloadCompleted = (!r.cfg.PhoneConsentSmoke || r.phoneConsentValid.Load()) && + (!r.cfg.SecurityCodeSmoke || r.securityCodeValid.Load()) + } res := result{ Variant: r.cfg.Variant, Revision: revision, StartedAt: startedTime, - Completed: completed && r.failed.Load() == 0 && r.sent.Load() == r.cfg.Total, - TargetMessages: r.cfg.Total, + Completed: completed && r.failed.Load() == 0 && workloadCompleted, + TargetMessages: targetMessages, Workload: r.cfg.Workload, MessagesReceived: r.received.Load(), MessagesSent: r.sent.Load(), @@ -880,6 +903,7 @@ func (r *runner) snapshot(completed bool) result { BusinessAppValidated: r.businessValid.Load(), PhoneConsentValidated: r.phoneConsentValid.Load(), SecurityCodeValidated: r.securityCodeValid.Load(), + SmokeOnly: r.cfg.SmokeOnly, } r.metricsMu.Lock() if r.sessionStarted { diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 41d1490f7..0daa16cf0 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -33,6 +33,7 @@ func TestLoadConfigWorkload(t *testing.T) { t.Setenv("BENCH_BUSINESS_SMOKE", "1") t.Setenv("BENCH_PHONE_CONSENT_SMOKE", "1") t.Setenv("BENCH_SECURITY_CODE_SMOKE", "1") + t.Setenv("BENCH_SMOKE_ONLY", "1") cfg, err := loadConfig() if err != nil { @@ -56,6 +57,16 @@ func TestLoadConfigWorkload(t *testing.T) { if !cfg.SecurityCodeSmoke { t.Fatal("security code smoke validation was not enabled") } + if !cfg.SmokeOnly { + t.Fatal("smoke-only mode was not enabled") + } +} + +func TestLoadConfigRequiresSecuritySmokeIsolation(t *testing.T) { + t.Setenv("BENCH_SECURITY_CODE_SMOKE", "true") + if _, err := loadConfig(); err == nil { + t.Fatal("security code smoke was accepted without smoke-only isolation") + } } func TestLoadConfigRejectsInvalidWorkload(t *testing.T) { @@ -151,6 +162,9 @@ func TestPhoneConsentFailureIsSharedAcrossWorkers(t *testing.T) { if calls.Load() != 1 { t.Fatalf("validation calls = %d, want 1", calls.Load()) } + if r.failed.Load() != 0 { + t.Fatalf("security code validation changed workload send failures to %d", r.failed.Load()) + } } func TestPhoneConsentSmokeRunsBeforeMeasuredMetrics(t *testing.T) { @@ -367,6 +381,16 @@ func TestOptionalSmokesFinishBeforeMeasuredMetrics(t *testing.T) { } } +func TestSmokeOnlyResultCompletesWithoutMeasuredMessages(t *testing.T) { + r := &runner{cfg: config{SecurityCodeSmoke: true, SmokeOnly: true, Total: 100}} + r.securityCodeValid.Store(true) + + result := r.snapshot(true) + if !result.Completed || result.TargetMessages != 0 || !result.SmokeOnly { + t.Fatalf("unexpected smoke-only result: %+v", result) + } +} + func TestSecurityCodeFailureIsSharedAcrossWorkers(t *testing.T) { r := &runner{ fatalErr: make(chan error, 1), diff --git a/benchmark/barback/compose.yaml b/benchmark/barback/compose.yaml index a4573ab6a..71d034677 100644 --- a/benchmark/barback/compose.yaml +++ b/benchmark/barback/compose.yaml @@ -116,6 +116,7 @@ services: BENCH_BUSINESS_SMOKE: ${BENCH_BUSINESS_SMOKE:-false} BENCH_PHONE_CONSENT_SMOKE: ${BENCH_PHONE_CONSENT_SMOKE:-false} BENCH_SECURITY_CODE_SMOKE: ${BENCH_SECURITY_CODE_SMOKE:-false} + BENCH_SMOKE_ONLY: ${BENCH_SMOKE_ONLY:-false} BENCH_MODE: ${BENCH_MODE:-group} BENCH_MESSAGE_PROFILE: ${BENCH_MESSAGE_PROFILE:-text} BENCH_RATE: ${BENCH_RATE:-50} diff --git a/benchmark/barback/run-comparison-matrix.sh b/benchmark/barback/run-comparison-matrix.sh index 463864616..d8ce250e5 100755 --- a/benchmark/barback/run-comparison-matrix.sh +++ b/benchmark/barback/run-comparison-matrix.sh @@ -77,7 +77,10 @@ run_revision() { if ((repeats > 1)); then variant="${name}-r${repeat}" fi - LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="$variant" BENCH_BUILD_TAGS="$build_tags" BENCH_SECURITY_CODE_SMOKE="$security_code_smoke" ./run-system-matrix.sh "$scenario" + if [[ $security_code_smoke == true ]]; then + LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="${variant}-security-smoke" BENCH_BUILD_TAGS="$build_tags" BENCH_SECURITY_CODE_SMOKE=true BENCH_SMOKE_ONLY=true MEM_PROFILE_SCENARIO= ./run-system-matrix.sh "$scenario" + fi + LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="$variant" BENCH_BUILD_TAGS="$build_tags" BENCH_SECURITY_CODE_SMOKE=false BENCH_SMOKE_ONLY=false ./run-system-matrix.sh "$scenario" } for ((repeat = repeat_start; repeat <= repeats; repeat++)); do diff --git a/notification.go b/notification.go index c9e9615e8..b61ee3931 100644 --- a/notification.go +++ b/notification.go @@ -176,6 +176,10 @@ func (cli *Client) handleDeviceNotification(ctx context.Context, node *waBinary. changedDeviceLID := deviceChild.AttrGetter().OptionalJID("lid") switch child.Tag { case "add": + if hasCachedLID && (changedDeviceLID == nil || deviceLIDHash == "") { + delete(cli.userDevicesCache, *fromLID) + hasCachedLID = false + } if hasCachedPN { cached.devices = append(cached.devices, changedDeviceJID) } @@ -183,6 +187,10 @@ func (cli *Client) handleDeviceNotification(ctx context.Context, node *waBinary. cachedLID.devices = append(cachedLID.devices, *changedDeviceLID) } case "remove": + if hasCachedLID && (changedDeviceLID == nil || deviceLIDHash == "") { + delete(cli.userDevicesCache, *fromLID) + hasCachedLID = false + } if hasCachedPN { cached.devices = slices.DeleteFunc(cached.devices, func(existing types.JID) bool { return existing == changedDeviceJID diff --git a/notification_device_test.go b/notification_device_test.go index dd7afef74..ee9b253da 100644 --- a/notification_device_test.go +++ b/notification_device_test.go @@ -43,3 +43,52 @@ func TestDeviceNotificationUpdatesLIDOnlyCache(t *testing.T) { t.Fatalf("LID cache was not updated: %#v", got) } } + +func TestDeviceNotificationInvalidatesLIDCacheWithoutCompleteMetadata(t *testing.T) { + pn := types.NewJID("15551234567", types.DefaultUserServer) + lid := types.NewJID("123456789012345", types.HiddenUserServer) + existingLID := types.NewADJID(lid.User, 0, 1) + addedLID := types.NewADJID(lid.User, 0, 2) + addedPN := types.NewADJID(pn.User, 0, 2) + + tests := []struct { + name string + childAttrs waBinary.Attrs + attrs waBinary.Attrs + }{ + { + name: "missing device LID", + attrs: waBinary.Attrs{"device_hash": "unused", "device_lid_hash": "unused"}, + childAttrs: waBinary.Attrs{"jid": addedPN}, + }, + { + name: "missing LID hash", + attrs: waBinary.Attrs{"device_hash": "unused"}, + childAttrs: waBinary.Attrs{"jid": addedPN, "lid": addedLID}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cli := &Client{ + Store: store.NoopDevice, + Log: waLog.Noop, + userDevicesCache: map[types.JID]deviceCache{ + lid: {devices: []types.JID{existingLID}, dhash: participantListHashV2([]types.JID{existingLID})}, + }, + } + cli.handleDeviceNotification(context.Background(), &waBinary.Node{ + Tag: "notification", + Attrs: waBinary.Attrs{"from": pn, "lid": lid}, + Content: []waBinary.Node{{ + Tag: "add", + Attrs: test.attrs, + Content: []waBinary.Node{{Tag: "device", Attrs: test.childAttrs}}, + }}, + }) + if _, ok := cli.userDevicesCache[lid]; ok { + t.Fatal("incomplete notification retained the LID device cache") + } + }) + } +} diff --git a/security_code.go b/security_code.go index c4e59282a..5bc3124f2 100644 --- a/security_code.go +++ b/security_code.go @@ -195,16 +195,13 @@ func (cli *Client) readIdentityKeys(ctx context.Context, devices []types.JID) ([ } key := response.bundle.IdentityKey().PublicKey().PublicKey() address := device.SignalAddress().String() - trusted, trustErr := cli.Store.Identities.IsTrustedIdentity(ctx, address, key) + trusted, trustErr := reader.EnsureIdentity(ctx, address, key) if trustErr != nil { - return nil, fmt.Errorf("check identity key for %s: %w", device, trustErr) + return nil, fmt.Errorf("ensure identity key for %s: %w", device, trustErr) } if !trusted { return nil, fmt.Errorf("identity key for %s is not trusted", device) } - if putErr := cli.Store.Identities.PutIdentity(ctx, address, key); putErr != nil { - return nil, fmt.Errorf("store identity key for %s: %w", device, putErr) - } stored[address] = key } } diff --git a/security_code_test.go b/security_code_test.go index 44e9ed5da..a267ced4d 100644 --- a/security_code_test.go +++ b/security_code_test.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "slices" + "sync" "testing" "google.golang.org/protobuf/proto" @@ -16,6 +17,7 @@ import ( ) type identityReaderStore struct { + lock sync.Mutex keys map[string][32]byte includeAll bool } @@ -27,6 +29,8 @@ func (*identityReaderStore) IsTrustedIdentity(context.Context, string, [32]byte) return true, nil } func (irs *identityReaderStore) GetManyIdentities(_ context.Context, addresses []string) (map[string][32]byte, error) { + irs.lock.Lock() + defer irs.lock.Unlock() result := make(map[string][32]byte, len(addresses)) if irs.includeAll { for address, key := range irs.keys { @@ -41,6 +45,18 @@ func (irs *identityReaderStore) GetManyIdentities(_ context.Context, addresses [ } return result, nil } +func (irs *identityReaderStore) EnsureIdentity(_ context.Context, address string, key [32]byte) (bool, error) { + irs.lock.Lock() + defer irs.lock.Unlock() + if existing, ok := irs.keys[address]; ok { + return existing == key, nil + } + if irs.keys == nil { + irs.keys = make(map[string][32]byte) + } + irs.keys[address] = key + return true, nil +} func TestReadIdentityKeysIgnoresUnrequestedReaderEntries(t *testing.T) { device := types.NewADJID("100000000000001", types.LIDDomain, 1) diff --git a/store/noop.go b/store/noop.go index 53dfdde75..428c7f2f4 100644 --- a/store/noop.go +++ b/store/noop.go @@ -65,6 +65,10 @@ func (n *NoopStore) GetManyIdentities(ctx context.Context, addresses []string) ( return nil, n.Error } +func (n *NoopStore) EnsureIdentity(ctx context.Context, address string, key [32]byte) (bool, error) { + return false, n.Error +} + func (n *NoopStore) GetSession(ctx context.Context, address string) ([]byte, error) { return nil, n.Error } diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 291337ac8..39f4c8e30 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -136,7 +136,11 @@ const ( INSERT INTO whatsmeow_identity_keys (our_jid, their_id, identity) VALUES ($1, $2, $3) ON CONFLICT (our_jid, their_id) DO UPDATE SET identity=excluded.identity ` - deleteAllIdentitiesQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2` + ensureIdentityQuery = ` + INSERT INTO whatsmeow_identity_keys (our_jid, their_id, identity) VALUES ($1, $2, $3) + ON CONFLICT (our_jid, their_id) DO NOTHING + ` + deleteAllIdentitiesQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3` deleteIdentityQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2` getIdentityQuery = `SELECT identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2` getManyIdentityQueryPostgres = `SELECT their_id, identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id = ANY($2)` @@ -165,7 +169,8 @@ func (s *SQLStore) PutIdentity(ctx context.Context, address string, key [32]byte func (s *SQLStore) DeleteAllIdentities(ctx context.Context, phone string) error { s.identityCacheLock.Lock() defer s.identityCacheLock.Unlock() - _, err := s.db.Exec(ctx, deleteAllIdentitiesQuery, s.JID, phone+":%") + lower, upper := signalAddressRange(phone) + _, err := s.db.Exec(ctx, deleteAllIdentitiesQuery, s.JID, lower, upper) if err == nil { for address := range s.identityCache { if strings.HasPrefix(address, phone+":") { @@ -214,6 +219,27 @@ func (s *SQLStore) IsTrustedIdentity(ctx context.Context, address string, key [3 return existingKey == key, nil } +func (s *SQLStore) EnsureIdentity(ctx context.Context, address string, key [32]byte) (bool, error) { + s.identityCacheLock.Lock() + defer s.identityCacheLock.Unlock() + if cached, ok := s.identityCache[address]; ok && cached.Present { + return cached.Key == key, nil + } + if _, err := s.db.Exec(ctx, ensureIdentityQuery, s.JID, address, key[:]); err != nil { + return false, err + } + var existingIdentity []byte + if err := s.db.QueryRow(ctx, getIdentityQuery, s.JID, address).Scan(&existingIdentity); err != nil { + return false, err + } + if len(existingIdentity) != 32 { + return false, ErrInvalidLength + } + existingKey := *(*[32]byte)(existingIdentity) + s.setCachedIdentityLocked(address, identityCacheEntry{Key: existingKey, Present: true}) + return existingKey == key, nil +} + type addressIdentityTuple struct { Address string Identity []byte @@ -339,7 +365,7 @@ const ( FROM UNNEST($2::text[], $3::bytea[]) AS batch(their_id, session) ON CONFLICT (our_jid, their_id) DO UPDATE SET session=excluded.session ` - deleteAllSessionsQuery = `DELETE FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id LIKE $2` + deleteAllSessionsQuery = `DELETE FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3` deleteSessionQuery = `DELETE FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id=$2` migratePNToLIDSessionsQuery = ` @@ -551,10 +577,15 @@ func (s *SQLStore) DeleteAllSessions(ctx context.Context, phone string) error { } func (s *SQLStore) deleteAllSessions(ctx context.Context, phone string) error { - _, err := s.db.Exec(ctx, deleteAllSessionsQuery, s.JID, phone+":%") + lower, upper := signalAddressRange(phone) + _, err := s.db.Exec(ctx, deleteAllSessionsQuery, s.JID, lower, upper) return err } +func signalAddressRange(user string) (string, string) { + return user + ":", user + ";" +} + func (s *SQLStore) deleteAllSenderKeys(ctx context.Context, phone string) error { _, err := s.db.Exec(ctx, deleteAllSenderKeysQuery, s.JID, phone+":%") return err diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index a01fffad9..de72f26f7 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -108,6 +108,51 @@ type contactUsernameRaceTx struct{} func (contactUsernameRaceTx) Commit() error { return nil } func (contactUsernameRaceTx) Rollback() error { return nil } +type ensureIdentityConnector struct { + key [32]byte +} + +func (connector *ensureIdentityConnector) Connect(context.Context) (driver.Conn, error) { + return &ensureIdentityConn{key: connector.key}, nil +} + +func (*ensureIdentityConnector) Driver() driver.Driver { return pnMigrationTestDriver{} } + +type ensureIdentityConn struct { + key [32]byte +} + +func (*ensureIdentityConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} + +func (*ensureIdentityConn) Close() error { return nil } +func (*ensureIdentityConn) Begin() (driver.Tx, error) { + return nil, errors.New("unexpected transaction") +} +func (*ensureIdentityConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) { + return driver.RowsAffected(0), nil +} +func (conn *ensureIdentityConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + return &ensureIdentityRows{key: conn.key}, nil +} + +type ensureIdentityRows struct { + key [32]byte + read bool +} + +func (*ensureIdentityRows) Columns() []string { return []string{"identity"} } +func (*ensureIdentityRows) Close() error { return nil } +func (rows *ensureIdentityRows) Next(values []driver.Value) error { + if rows.read { + return io.EOF + } + rows.read = true + values[0] = rows.key[:] + return nil +} + func TestNewSQLStoreDefersCaches(t *testing.T) { store := NewSQLStore(nil, types.JID{User: "123", Server: types.DefaultUserServer}) if store.contactCache != nil || store.identityCache != nil || store.migratedPNSessionsCache != nil || store.emptyPNMigrationCache != nil || store.migratingPNSessions != nil { @@ -190,6 +235,60 @@ func TestDeleteIdentityLeavesNegativeCacheEntry(t *testing.T) { } } +func TestSignalAddressRangeTreatsDomainSuffixLiterally(t *testing.T) { + lower, upper := signalAddressRange("123_1") + if lower != "123_1:" || upper != "123_1;" { + t.Fatalf("signal address range = [%q, %q)", lower, upper) + } + for name, query := range map[string]string{ + "identity": deleteAllIdentitiesQuery, + "session": deleteAllSessionsQuery, + } { + if strings.Contains(query, "LIKE") || !strings.Contains(query, "their_id >= $2 AND their_id < $3") { + t.Fatalf("%s deletion is not a literal prefix range: %s", name, query) + } + } +} + +func TestEnsureIdentityRejectsDifferentCachedKey(t *testing.T) { + address := "100000011111111_1:7" + storedKey := [32]byte{1} + sqlStore := &SQLStore{identityCache: map[string]identityCacheEntry{ + address: {Key: storedKey, Present: true}, + }} + + trusted, err := sqlStore.EnsureIdentity(context.Background(), address, [32]byte{2}) + if err != nil { + t.Fatal(err) + } + if trusted { + t.Fatal("different cached identity was trusted") + } + if got := sqlStore.identityCache[address]; !got.Present || got.Key != storedKey { + t.Fatalf("stored identity was overwritten: %#v", got) + } +} + +func TestEnsureIdentityDoesNotOverwriteDatabaseKeyAfterNegativeCache(t *testing.T) { + address := "100000011111111_1:7" + storedKey := [32]byte{1} + db := sql.OpenDB(&ensureIdentityConnector{key: storedKey}) + t.Cleanup(func() { _ = db.Close() }) + sqlStore := NewSQLStore(NewWithDB(db, "postgres", nil), types.NewJID("15550000000", types.DefaultUserServer)) + sqlStore.identityCache = map[string]identityCacheEntry{address: {}} + + trusted, err := sqlStore.EnsureIdentity(context.Background(), address, [32]byte{2}) + if err != nil { + t.Fatal(err) + } + if trusted { + t.Fatal("different database identity was trusted") + } + if got := sqlStore.identityCache[address]; !got.Present || got.Key != storedKey { + t.Fatalf("database identity was not retained: %#v", got) + } +} + func TestContactCacheIsBounded(t *testing.T) { store := &SQLStore{contactCache: make(map[types.JID]*types.ContactInfo, maxContactCacheEntries)} for i := 0; i < maxContactCacheEntries; i++ { diff --git a/store/store.go b/store/store.go index e88f7ab66..e6a26145f 100644 --- a/store/store.go +++ b/store/store.go @@ -30,6 +30,7 @@ type IdentityStore interface { type IdentityKeyReader interface { GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, error) + EnsureIdentity(ctx context.Context, address string, key [32]byte) (bool, error) } type SessionStore interface { From baf1ba8a7ee3aef5315d5cc65f331c5ce72ddd72 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 14:14:15 +0300 Subject: [PATCH 141/163] fix: reject identities fetched before deletion --- security_code.go | 4 ++-- security_code_test.go | 12 ++++++++---- store/noop.go | 6 +++--- store/sqlstore/identity_reader_test.go | 4 ++-- store/sqlstore/store.go | 25 ++++++++++++++++++------- store/sqlstore/store_test.go | 15 +++++++++++++-- store/store.go | 4 ++-- 7 files changed, 48 insertions(+), 22 deletions(-) diff --git a/security_code.go b/security_code.go index 5bc3124f2..223c4013a 100644 --- a/security_code.go +++ b/security_code.go @@ -167,7 +167,7 @@ func (cli *Client) readIdentityKeys(ctx context.Context, devices []types.JID) ([ addresses = append(addresses, address) deviceByAddress[address] = device } - stored, err := reader.GetManyIdentities(ctx, addresses) + stored, deleteGeneration, err := reader.GetManyIdentities(ctx, addresses) if err != nil { return nil, fmt.Errorf("read identity keys: %w", err) } @@ -195,7 +195,7 @@ func (cli *Client) readIdentityKeys(ctx context.Context, devices []types.JID) ([ } key := response.bundle.IdentityKey().PublicKey().PublicKey() address := device.SignalAddress().String() - trusted, trustErr := reader.EnsureIdentity(ctx, address, key) + trusted, trustErr := reader.EnsureIdentity(ctx, address, key, deleteGeneration) if trustErr != nil { return nil, fmt.Errorf("ensure identity key for %s: %w", device, trustErr) } diff --git a/security_code_test.go b/security_code_test.go index a267ced4d..6510edf59 100644 --- a/security_code_test.go +++ b/security_code_test.go @@ -20,6 +20,7 @@ type identityReaderStore struct { lock sync.Mutex keys map[string][32]byte includeAll bool + generation uint64 } func (*identityReaderStore) PutIdentity(context.Context, string, [32]byte) error { return nil } @@ -28,7 +29,7 @@ func (*identityReaderStore) DeleteIdentity(context.Context, string) error func (*identityReaderStore) IsTrustedIdentity(context.Context, string, [32]byte) (bool, error) { return true, nil } -func (irs *identityReaderStore) GetManyIdentities(_ context.Context, addresses []string) (map[string][32]byte, error) { +func (irs *identityReaderStore) GetManyIdentities(_ context.Context, addresses []string) (map[string][32]byte, uint64, error) { irs.lock.Lock() defer irs.lock.Unlock() result := make(map[string][32]byte, len(addresses)) @@ -36,18 +37,21 @@ func (irs *identityReaderStore) GetManyIdentities(_ context.Context, addresses [ for address, key := range irs.keys { result[address] = key } - return result, nil + return result, irs.generation, nil } for _, address := range addresses { if key, ok := irs.keys[address]; ok { result[address] = key } } - return result, nil + return result, irs.generation, nil } -func (irs *identityReaderStore) EnsureIdentity(_ context.Context, address string, key [32]byte) (bool, error) { +func (irs *identityReaderStore) EnsureIdentity(_ context.Context, address string, key [32]byte, deleteGeneration uint64) (bool, error) { irs.lock.Lock() defer irs.lock.Unlock() + if deleteGeneration != irs.generation { + return false, nil + } if existing, ok := irs.keys[address]; ok { return existing == key, nil } diff --git a/store/noop.go b/store/noop.go index 428c7f2f4..c88adcaa5 100644 --- a/store/noop.go +++ b/store/noop.go @@ -61,11 +61,11 @@ func (n *NoopStore) IsTrustedIdentity(ctx context.Context, address string, key [ return false, n.Error } -func (n *NoopStore) GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, error) { - return nil, n.Error +func (n *NoopStore) GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, uint64, error) { + return nil, 0, n.Error } -func (n *NoopStore) EnsureIdentity(ctx context.Context, address string, key [32]byte) (bool, error) { +func (n *NoopStore) EnsureIdentity(ctx context.Context, address string, key [32]byte, deleteGeneration uint64) (bool, error) { return false, n.Error } diff --git a/store/sqlstore/identity_reader_test.go b/store/sqlstore/identity_reader_test.go index e5e2c17b6..104101a46 100644 --- a/store/sqlstore/identity_reader_test.go +++ b/store/sqlstore/identity_reader_test.go @@ -95,14 +95,14 @@ func TestGetManyIdentitiesUsesOneQueryAndCachesResults(t *testing.T) { ) addresses := []string{"100000000000001:1", "100000000000001:2"} - got, err := store.GetManyIdentities(context.Background(), addresses) + got, _, err := store.GetManyIdentities(context.Background(), addresses) if err != nil { t.Fatal(err) } if len(got) != 2 || got[addresses[0]][0] != 0x11 || got[addresses[1]][0] != 0x22 { t.Fatalf("unexpected identities: %#v", got) } - if _, err = store.GetManyIdentities(context.Background(), addresses); err != nil { + if _, _, err = store.GetManyIdentities(context.Background(), addresses); err != nil { t.Fatal(err) } if state.queries != 1 { diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 39f4c8e30..4d82e7f44 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -75,6 +75,7 @@ type SQLStore struct { contactCacheLock sync.Mutex identityCache map[string]identityCacheEntry identityCacheLock sync.RWMutex + identityDeleteGen uint64 migratedPNSessionsCache map[string]struct{} emptyPNMigrationCache map[string]time.Time @@ -172,6 +173,7 @@ func (s *SQLStore) DeleteAllIdentities(ctx context.Context, phone string) error lower, upper := signalAddressRange(phone) _, err := s.db.Exec(ctx, deleteAllIdentitiesQuery, s.JID, lower, upper) if err == nil { + s.identityDeleteGen++ for address := range s.identityCache { if strings.HasPrefix(address, phone+":") { s.setCachedIdentityLocked(address, identityCacheEntry{}) @@ -186,6 +188,7 @@ func (s *SQLStore) DeleteIdentity(ctx context.Context, address string) error { defer s.identityCacheLock.Unlock() _, err := s.db.Exec(ctx, deleteIdentityQuery, s.JID, address) if err == nil { + s.identityDeleteGen++ s.setCachedIdentityLocked(address, identityCacheEntry{}) } return err @@ -219,9 +222,12 @@ func (s *SQLStore) IsTrustedIdentity(ctx context.Context, address string, key [3 return existingKey == key, nil } -func (s *SQLStore) EnsureIdentity(ctx context.Context, address string, key [32]byte) (bool, error) { +func (s *SQLStore) EnsureIdentity(ctx context.Context, address string, key [32]byte, deleteGeneration uint64) (bool, error) { s.identityCacheLock.Lock() defer s.identityCacheLock.Unlock() + if deleteGeneration != s.identityDeleteGen { + return false, nil + } if cached, ok := s.identityCache[address]; ok && cached.Present { return cached.Key == key, nil } @@ -264,14 +270,18 @@ func (s *SQLStore) queryManyIdentities(ctx context.Context, addresses []string) return s.db.Query(ctx, fmt.Sprintf(getManyIdentityQueryGeneric, strings.Join(placeholders, ",")), args...) } -func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, error) { +func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, uint64, error) { if len(addresses) == 0 { - return nil, nil + s.identityCacheLock.RLock() + generation := s.identityDeleteGen + s.identityCacheLock.RUnlock() + return nil, generation, nil } result := make(map[string][32]byte, len(addresses)) missing := make([]string, 0, len(addresses)) seen := make(map[string]struct{}, len(addresses)) s.identityCacheLock.RLock() + generation := s.identityDeleteGen for _, address := range addresses { if _, duplicate := seen[address]; duplicate { continue @@ -287,11 +297,12 @@ func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (m } s.identityCacheLock.RUnlock() if len(missing) == 0 { - return result, nil + return result, generation, nil } s.identityCacheLock.Lock() defer s.identityCacheLock.Unlock() + generation = s.identityDeleteGen stillMissing := missing[:0] for _, address := range missing { if cached, ok := s.identityCache[address]; ok { @@ -304,7 +315,7 @@ func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (m } missing = stillMissing if len(missing) == 0 { - return result, nil + return result, generation, nil } rows, err := s.queryManyIdentities(ctx, missing) @@ -322,10 +333,10 @@ func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (m return true, nil }) if err != nil { - return nil, err + return nil, 0, err } s.cacheFetchedIdentitiesLocked(result, fetched) - return result, nil + return result, generation, nil } func (s *SQLStore) cacheFetchedIdentities(result map[string][32]byte, fetched map[string]identityCacheEntry) { diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index de72f26f7..f67b3e2f2 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -257,7 +257,7 @@ func TestEnsureIdentityRejectsDifferentCachedKey(t *testing.T) { address: {Key: storedKey, Present: true}, }} - trusted, err := sqlStore.EnsureIdentity(context.Background(), address, [32]byte{2}) + trusted, err := sqlStore.EnsureIdentity(context.Background(), address, [32]byte{2}, 0) if err != nil { t.Fatal(err) } @@ -277,7 +277,7 @@ func TestEnsureIdentityDoesNotOverwriteDatabaseKeyAfterNegativeCache(t *testing. sqlStore := NewSQLStore(NewWithDB(db, "postgres", nil), types.NewJID("15550000000", types.DefaultUserServer)) sqlStore.identityCache = map[string]identityCacheEntry{address: {}} - trusted, err := sqlStore.EnsureIdentity(context.Background(), address, [32]byte{2}) + trusted, err := sqlStore.EnsureIdentity(context.Background(), address, [32]byte{2}, 0) if err != nil { t.Fatal(err) } @@ -289,6 +289,17 @@ func TestEnsureIdentityDoesNotOverwriteDatabaseKeyAfterNegativeCache(t *testing. } } +func TestEnsureIdentityRejectsFetchStartedBeforeDeletion(t *testing.T) { + sqlStore := &SQLStore{identityDeleteGen: 2} + trusted, err := sqlStore.EnsureIdentity(context.Background(), "100000011111111_1:7", [32]byte{1}, 1) + if err != nil { + t.Fatal(err) + } + if trusted { + t.Fatal("identity fetched before deletion was trusted") + } +} + func TestContactCacheIsBounded(t *testing.T) { store := &SQLStore{contactCache: make(map[types.JID]*types.ContactInfo, maxContactCacheEntries)} for i := 0; i < maxContactCacheEntries; i++ { diff --git a/store/store.go b/store/store.go index e6a26145f..5e5e81954 100644 --- a/store/store.go +++ b/store/store.go @@ -29,8 +29,8 @@ type IdentityStore interface { } type IdentityKeyReader interface { - GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, error) - EnsureIdentity(ctx context.Context, address string, key [32]byte) (bool, error) + GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, uint64, error) + EnsureIdentity(ctx context.Context, address string, key [32]byte, deleteGeneration uint64) (bool, error) } type SessionStore interface { From 8e23f7d8bc46c42cbe1958eff32ed94683904a99 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 9 Aug 2026 20:07:29 +0300 Subject: [PATCH 142/163] feat: add typed native flow messages --- business_message_builders.go | 97 +++++++++++++++++++++++++++++++ business_message_builders_test.go | 55 ++++++++++++++++++ send.go | 41 ++++++++++--- send_test.go | 29 +++++++++ 4 files changed, 215 insertions(+), 7 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index fea01d5b3..3077e8b00 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -94,6 +94,25 @@ type BusinessNativeFlowButtonsMessageParams struct { ContextInfo *waE2E.ContextInfo } +type BusinessAddressMessageParams struct { + Body string + ButtonText string + Footer string + ContextInfo *waE2E.ContextInfo +} + +type BusinessFlowMessageParams struct { + Body string + ButtonText string + Footer string + FlowID string + FlowToken string + FlowAction string + Screen string + DataJSON string + ContextInfo *waE2E.ContextInfo +} + func validBusinessOwner(jid types.JID) bool { return !jid.IsEmpty() && jid.User != "" && (jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer) } @@ -316,3 +335,81 @@ func BuildBusinessNativeFlowButtonsMessage(params BusinessNativeFlowButtonsMessa } return &waE2E.Message{ButtonsMessage: message}, nil } + +func BuildBusinessAddressMessage(params BusinessAddressMessageParams) (*waE2E.Message, error) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 4096) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 256) || !bounded(params.Footer, 256) { + return nil, errors.New("invalid business address message text") + } + buttonParams, err := json.Marshal(struct { + DisplayText string `json:"display_text"` + }{DisplayText: params.ButtonText}) + if err != nil { + return nil, fmt.Errorf("marshal business address message: %w", err) + } + return buildBusinessInteractiveNativeFlow(params.Body, params.Footer, "address_message", string(buttonParams), params.ContextInfo), nil +} + +func BuildBusinessFlowMessage(params BusinessFlowMessageParams) (*waE2E.Message, error) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 4096) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 256) || !bounded(params.Footer, 256) { + return nil, errors.New("invalid business flow message text") + } + if strings.TrimSpace(params.FlowID) == "" || !bounded(params.FlowID, 256) || strings.TrimSpace(params.FlowToken) == "" || !bounded(params.FlowToken, 8192) { + return nil, errors.New("invalid business flow identity") + } + if params.FlowAction != "navigate" && params.FlowAction != "data_exchange" { + return nil, errors.New("invalid business flow action") + } + if !bounded(params.Screen, 256) || (params.FlowAction == "navigate" && strings.TrimSpace(params.Screen) == "") { + return nil, errors.New("invalid business flow screen") + } + if params.FlowAction == "data_exchange" && (params.Screen != "" || params.DataJSON != "") { + return nil, errors.New("data-exchange flow messages cannot include an action payload") + } + if !bounded(params.DataJSON, 16*1024) { + return nil, errors.New("business flow data is too large") + } + var data map[string]any + if params.DataJSON != "" { + if err := json.Unmarshal([]byte(params.DataJSON), &data); err != nil || data == nil { + return nil, errors.New("business flow data must be a JSON object") + } + } + type actionPayload struct { + Screen string `json:"screen,omitempty"` + Data map[string]any `json:"data,omitempty"` + } + var payload *actionPayload + if params.FlowAction == "navigate" { + payload = &actionPayload{Screen: params.Screen, Data: data} + } + buttonParams, err := json.Marshal(struct { + Version string `json:"flow_message_version"` + Token string `json:"flow_token"` + ID string `json:"flow_id"` + CTA string `json:"flow_cta"` + Action string `json:"flow_action"` + ActionPayload *actionPayload `json:"flow_action_payload,omitempty"` + }{ + Version: "3", Token: params.FlowToken, ID: params.FlowID, CTA: params.ButtonText, Action: params.FlowAction, + ActionPayload: payload, + }) + if err != nil { + return nil, fmt.Errorf("marshal business flow message: %w", err) + } + return buildBusinessInteractiveNativeFlow(params.Body, params.Footer, "galaxy_message", string(buttonParams), params.ContextInfo), nil +} + +func buildBusinessInteractiveNativeFlow(body, footer, name, buttonParams string, contextInfo *waE2E.ContextInfo) *waE2E.Message { + interactive := &waE2E.InteractiveMessage{ + Body: &waE2E.InteractiveMessage_Body{Text: proto.String(body)}, + ContextInfo: contextInfo, + InteractiveMessage: &waE2E.InteractiveMessage_NativeFlowMessage_{NativeFlowMessage: &waE2E.InteractiveMessage_NativeFlowMessage{ + Buttons: []*waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton{{Name: proto.String(name), ButtonParamsJSON: proto.String(buttonParams)}}, + MessageVersion: proto.Int32(1), + }}, + } + if footer != "" { + interactive.Footer = &waE2E.InteractiveMessage_Footer{Text: proto.String(footer)} + } + return &waE2E.Message{InteractiveMessage: interactive} +} diff --git a/business_message_builders_test.go b/business_message_builders_test.go index 8f84c68ae..62c578d27 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -1,6 +1,7 @@ package whatsmeow import ( + "encoding/json" "fmt" "strings" "testing" @@ -233,6 +234,52 @@ func TestBusinessListBuildersRejectOversizedSectionsBeforeAllocating(t *testing. } } +func TestBuildBusinessAddressMessageMatchesWebGenerator(t *testing.T) { + msg, err := BuildBusinessAddressMessage(BusinessAddressMessageParams{ + Body: "Where should we deliver?", ButtonText: "Share address", Footer: "Synthetic checkout", + ContextInfo: &waE2E.ContextInfo{StanzaID: testPtr("quoted-message")}, + }) + if err != nil { + t.Fatal(err) + } + interactive := msg.GetInteractiveMessage() + flow := interactive.GetNativeFlowMessage() + if interactive.GetBody().GetText() != "Where should we deliver?" || interactive.GetFooter().GetText() != "Synthetic checkout" { + t.Fatalf("unexpected address envelope: %#v", interactive) + } + if len(flow.GetButtons()) != 1 || flow.GetButtons()[0].GetName() != "address_message" || flow.GetButtons()[0].GetButtonParamsJSON() != `{"display_text":"Share address"}` { + t.Fatalf("unexpected address native flow: %#v", flow) + } + if flow.GetMessageVersion() != 1 || interactive.GetContextInfo().GetStanzaID() != "quoted-message" { + t.Fatalf("unexpected address metadata: %#v", interactive) + } +} + +func TestBuildBusinessFlowMessageMatchesWebGenerator(t *testing.T) { + msg, err := BuildBusinessFlowMessage(BusinessFlowMessageParams{ + Body: "Book a visit", ButtonText: "Choose a time", FlowID: "flow-100", FlowToken: "synthetic-token", + FlowAction: "navigate", Screen: "APPOINTMENT", DataJSON: `{"location":"beirut"}`, + }) + if err != nil { + t.Fatal(err) + } + flow := msg.GetInteractiveMessage().GetNativeFlowMessage() + if len(flow.GetButtons()) != 1 || flow.GetButtons()[0].GetName() != "galaxy_message" || flow.GetMessageVersion() != 1 { + t.Fatalf("unexpected galaxy flow: %#v", flow) + } + var params map[string]any + if err := json.Unmarshal([]byte(flow.GetButtons()[0].GetButtonParamsJSON()), ¶ms); err != nil { + t.Fatal(err) + } + if params["flow_message_version"] != "3" || params["flow_id"] != "flow-100" || params["flow_token"] != "synthetic-token" || params["flow_cta"] != "Choose a time" || params["flow_action"] != "navigate" { + t.Fatalf("unexpected flow params: %#v", params) + } + payload := params["flow_action_payload"].(map[string]any) + if payload["screen"] != "APPOINTMENT" || payload["data"].(map[string]any)["location"] != "beirut" { + t.Fatalf("unexpected action payload: %#v", payload) + } +} + func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { if _, err := BuildBusinessProductMessage(BusinessProductMessageParams{ProductID: "p", Title: "Tea", CurrencyCode: "USD"}); err == nil { t.Fatal("expected missing owner to fail") @@ -284,6 +331,14 @@ func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { }); err == nil { t.Fatal("expected malformed native-flow parameters to fail") } + if _, err := BuildBusinessAddressMessage(BusinessAddressMessageParams{Body: "Address", ButtonText: ""}); err == nil { + t.Fatal("expected empty address CTA to fail") + } + if _, err := BuildBusinessFlowMessage(BusinessFlowMessageParams{ + Body: "Flow", ButtonText: "Open", FlowID: "flow", FlowToken: "token", FlowAction: "navigate", DataJSON: `[]`, + }); err == nil { + t.Fatal("expected non-object flow data to fail") + } } func TestBusinessProductListAndNativeFlowTextLimits(t *testing.T) { diff --git a/send.go b/send.go index de7971942..7e47987a7 100644 --- a/send.go +++ b/send.go @@ -996,6 +996,8 @@ func getButtonTypeFromMessage(msg *waE2E.Message) string { return "buttons" case msg.ListMessage != nil: return "list" + case msg.InteractiveMessage != nil && msg.InteractiveMessage.GetNativeFlowMessage() != nil: + return "native_flow" case msg.InteractiveResponseMessage != nil: return "interactive_response" default: @@ -1003,6 +1005,27 @@ func getButtonTypeFromMessage(msg *waE2E.Message) string { } } +func buildNativeFlowBizNode(msg *waE2E.Message, nowUnix int64) waBinary.Node { + name := "mixed" + if interactive := msg.GetInteractiveMessage(); interactive != nil { + if buttons := interactive.GetNativeFlowMessage().GetButtons(); len(buttons) > 0 && buttons[0].GetName() != "" { + name = buttons[0].GetName() + } + } + return waBinary.Node{ + Tag: "biz", + Attrs: waBinary.Attrs{ + "actual_actors": "2", + "host_storage": "2", + "privacy_mode_ts": strconv.FormatInt(nowUnix, 10), + }, + Content: []waBinary.Node{ + {Tag: "interactive", Attrs: waBinary.Attrs{"type": "native_flow", "v": "1"}, Content: []waBinary.Node{{Tag: "native_flow", Attrs: waBinary.Attrs{"name": name, "v": "9"}}}}, + {Tag: "quality_control", Attrs: waBinary.Attrs{"source_type": "third_party"}}, + }, + } +} + func getButtonAttributes(msg *waE2E.Message) waBinary.Attrs { switch { case msg.ViewOnceMessage != nil: @@ -1142,13 +1165,17 @@ func (cli *Client) getMessageContent( } if buttonType := getButtonTypeFromMessage(message); buttonType != "" { - content = append(content, waBinary.Node{ - Tag: "biz", - Content: []waBinary.Node{{ - Tag: buttonType, - Attrs: getButtonAttributes(message), - }}, - }) + if buttonType == "native_flow" { + content = append(content, buildNativeFlowBizNode(message, time.Now().Unix())) + } else { + content = append(content, waBinary.Node{ + Tag: "biz", + Content: []waBinary.Node{{ + Tag: buttonType, + Attrs: getButtonAttributes(message), + }}, + }) + } } return content } diff --git a/send_test.go b/send_test.go index 272d9261c..3c4fd86af 100644 --- a/send_test.go +++ b/send_test.go @@ -3,7 +3,9 @@ package whatsmeow import ( "testing" + waBinary "go.mau.fi/whatsmeow/binary" waE2E "go.mau.fi/whatsmeow/proto/waE2E" + "google.golang.org/protobuf/proto" ) func TestButtonAndListResponsesDoNotRequestBusinessMetadata(t *testing.T) { @@ -23,6 +25,33 @@ func TestButtonAndListResponsesDoNotRequestBusinessMetadata(t *testing.T) { } } +func TestInteractiveNativeFlowsRequestNamedBusinessMetadata(t *testing.T) { + for _, name := range []string{"address_message", "galaxy_message"} { + t.Run(name, func(t *testing.T) { + msg := &waE2E.Message{InteractiveMessage: &waE2E.InteractiveMessage{ + InteractiveMessage: &waE2E.InteractiveMessage_NativeFlowMessage_{NativeFlowMessage: &waE2E.InteractiveMessage_NativeFlowMessage{ + Buttons: []*waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton{{Name: proto.String(name)}}, + }}, + }} + if got := getButtonTypeFromMessage(msg); got != "native_flow" { + t.Fatalf("button type = %q", got) + } + biz := buildNativeFlowBizNode(msg, 1_700_000_000) + if biz.Tag != "biz" || biz.Attrs["actual_actors"] != "2" || biz.Attrs["host_storage"] != "2" || biz.Attrs["privacy_mode_ts"] != "1700000000" { + t.Fatalf("unexpected biz attrs: %#v", biz) + } + children, ok := biz.Content.([]waBinary.Node) + if !ok || len(children) != 2 || children[0].Tag != "interactive" { + t.Fatalf("unexpected biz children: %#v", biz.Content) + } + flowChildren := children[0].Content.([]waBinary.Node) + if flowChildren[0].Tag != "native_flow" || flowChildren[0].Attrs["name"] != name || flowChildren[0].Attrs["v"] != "9" { + t.Fatalf("unexpected native-flow metadata: %#v", flowChildren[0]) + } + }) + } +} + func TestSetParticipantHashMismatch(t *testing.T) { tests := []struct { name string From 779a8404d86a9eec9c12788536361651f27a83ae Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 00:44:33 +0300 Subject: [PATCH 143/163] build: format send test imports --- send_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/send_test.go b/send_test.go index 3c4fd86af..0b1524679 100644 --- a/send_test.go +++ b/send_test.go @@ -3,9 +3,10 @@ package whatsmeow import ( "testing" + "google.golang.org/protobuf/proto" + waBinary "go.mau.fi/whatsmeow/binary" waE2E "go.mau.fi/whatsmeow/proto/waE2E" - "google.golang.org/protobuf/proto" ) func TestButtonAndListResponsesDoNotRequestBusinessMetadata(t *testing.T) { From fd8f400c5f4a4a0873c54940b34db239bd9db355 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 01:21:59 +0300 Subject: [PATCH 144/163] fix: preserve native flow payload semantics --- business_message_builders.go | 6 +++--- business_message_builders_test.go | 13 ++++++++++++- send.go | 13 +++++++++++++ send_test.go | 22 ++++++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 3077e8b00..7cf1120dc 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -368,15 +368,15 @@ func BuildBusinessFlowMessage(params BusinessFlowMessageParams) (*waE2E.Message, if !bounded(params.DataJSON, 16*1024) { return nil, errors.New("business flow data is too large") } - var data map[string]any + var data map[string]json.RawMessage if params.DataJSON != "" { if err := json.Unmarshal([]byte(params.DataJSON), &data); err != nil || data == nil { return nil, errors.New("business flow data must be a JSON object") } } type actionPayload struct { - Screen string `json:"screen,omitempty"` - Data map[string]any `json:"data,omitempty"` + Screen string `json:"screen,omitempty"` + Data map[string]json.RawMessage `json:"data,omitempty"` } var payload *actionPayload if params.FlowAction == "navigate" { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index 62c578d27..d1b181a6c 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -258,7 +258,7 @@ func TestBuildBusinessAddressMessageMatchesWebGenerator(t *testing.T) { func TestBuildBusinessFlowMessageMatchesWebGenerator(t *testing.T) { msg, err := BuildBusinessFlowMessage(BusinessFlowMessageParams{ Body: "Book a visit", ButtonText: "Choose a time", FlowID: "flow-100", FlowToken: "synthetic-token", - FlowAction: "navigate", Screen: "APPOINTMENT", DataJSON: `{"location":"beirut"}`, + FlowAction: "navigate", Screen: "APPOINTMENT", DataJSON: `{"location":"beirut","order_id":9007199254740993}`, }) if err != nil { t.Fatal(err) @@ -278,6 +278,17 @@ func TestBuildBusinessFlowMessageMatchesWebGenerator(t *testing.T) { if payload["screen"] != "APPOINTMENT" || payload["data"].(map[string]any)["location"] != "beirut" { t.Fatalf("unexpected action payload: %#v", payload) } + var exact struct { + ActionPayload struct { + Data map[string]json.RawMessage `json:"data"` + } `json:"flow_action_payload"` + } + if err := json.Unmarshal([]byte(flow.GetButtons()[0].GetButtonParamsJSON()), &exact); err != nil { + t.Fatal(err) + } + if string(exact.ActionPayload.Data["order_id"]) != "9007199254740993" { + t.Fatalf("order ID lost precision: %s", exact.ActionPayload.Data["order_id"]) + } } func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { diff --git a/send.go b/send.go index 7e47987a7..501f85d01 100644 --- a/send.go +++ b/send.go @@ -1007,6 +1007,19 @@ func getButtonTypeFromMessage(msg *waE2E.Message) string { func buildNativeFlowBizNode(msg *waE2E.Message, nowUnix int64) waBinary.Node { name := "mixed" + for msg != nil { + switch { + case msg.ViewOnceMessage != nil: + msg = msg.ViewOnceMessage.Message + case msg.ViewOnceMessageV2 != nil: + msg = msg.ViewOnceMessageV2.Message + case msg.EphemeralMessage != nil: + msg = msg.EphemeralMessage.Message + default: + goto unwrapped + } + } +unwrapped: if interactive := msg.GetInteractiveMessage(); interactive != nil { if buttons := interactive.GetNativeFlowMessage().GetButtons(); len(buttons) > 0 && buttons[0].GetName() != "" { name = buttons[0].GetName() diff --git a/send_test.go b/send_test.go index 0b1524679..fef249301 100644 --- a/send_test.go +++ b/send_test.go @@ -53,6 +53,28 @@ func TestInteractiveNativeFlowsRequestNamedBusinessMetadata(t *testing.T) { } } +func TestNativeFlowBusinessMetadataUnwrapsMessages(t *testing.T) { + inner := &waE2E.Message{InteractiveMessage: &waE2E.InteractiveMessage{ + InteractiveMessage: &waE2E.InteractiveMessage_NativeFlowMessage_{NativeFlowMessage: &waE2E.InteractiveMessage_NativeFlowMessage{ + Buttons: []*waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton{{Name: proto.String("galaxy_message")}}, + }}, + }} + wrappers := map[string]*waE2E.Message{ + "view once": {ViewOnceMessage: &waE2E.FutureProofMessage{Message: inner}}, + "view once v2": {ViewOnceMessageV2: &waE2E.FutureProofMessage{Message: inner}}, + "ephemeral": {EphemeralMessage: &waE2E.FutureProofMessage{Message: inner}}, + } + for name, message := range wrappers { + t.Run(name, func(t *testing.T) { + biz := buildNativeFlowBizNode(message, 1_700_000_000) + flow := biz.Content.([]waBinary.Node)[0].Content.([]waBinary.Node)[0] + if flow.Attrs["name"] != "galaxy_message" { + t.Fatalf("native-flow name = %q", flow.Attrs["name"]) + } + }) + } +} + func TestSetParticipantHashMismatch(t *testing.T) { tests := []struct { name string From e6ba734e7b6362046e6bf0a333ff5907ea5ecefd Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:02:58 +0300 Subject: [PATCH 145/163] fix: unwrap extended view-once flows --- send.go | 4 ++++ send_test.go | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/send.go b/send.go index 501f85d01..c58a6af21 100644 --- a/send.go +++ b/send.go @@ -990,6 +990,8 @@ func getButtonTypeFromMessage(msg *waE2E.Message) string { return getButtonTypeFromMessage(msg.ViewOnceMessage.Message) case msg.ViewOnceMessageV2 != nil: return getButtonTypeFromMessage(msg.ViewOnceMessageV2.Message) + case msg.ViewOnceMessageV2Extension != nil: + return getButtonTypeFromMessage(msg.ViewOnceMessageV2Extension.Message) case msg.EphemeralMessage != nil: return getButtonTypeFromMessage(msg.EphemeralMessage.Message) case msg.ButtonsMessage != nil: @@ -1013,6 +1015,8 @@ func buildNativeFlowBizNode(msg *waE2E.Message, nowUnix int64) waBinary.Node { msg = msg.ViewOnceMessage.Message case msg.ViewOnceMessageV2 != nil: msg = msg.ViewOnceMessageV2.Message + case msg.ViewOnceMessageV2Extension != nil: + msg = msg.ViewOnceMessageV2Extension.Message case msg.EphemeralMessage != nil: msg = msg.EphemeralMessage.Message default: diff --git a/send_test.go b/send_test.go index fef249301..1b87211a2 100644 --- a/send_test.go +++ b/send_test.go @@ -60,12 +60,16 @@ func TestNativeFlowBusinessMetadataUnwrapsMessages(t *testing.T) { }}, }} wrappers := map[string]*waE2E.Message{ - "view once": {ViewOnceMessage: &waE2E.FutureProofMessage{Message: inner}}, - "view once v2": {ViewOnceMessageV2: &waE2E.FutureProofMessage{Message: inner}}, - "ephemeral": {EphemeralMessage: &waE2E.FutureProofMessage{Message: inner}}, + "view once": {ViewOnceMessage: &waE2E.FutureProofMessage{Message: inner}}, + "view once v2": {ViewOnceMessageV2: &waE2E.FutureProofMessage{Message: inner}}, + "view once v2 extension": {ViewOnceMessageV2Extension: &waE2E.FutureProofMessage{Message: inner}}, + "ephemeral": {EphemeralMessage: &waE2E.FutureProofMessage{Message: inner}}, } for name, message := range wrappers { t.Run(name, func(t *testing.T) { + if got := getButtonTypeFromMessage(message); got != "native_flow" { + t.Fatalf("button type = %q", got) + } biz := buildNativeFlowBizNode(message, 1_700_000_000) flow := biz.Content.([]waBinary.Node)[0].Content.([]waBinary.Node)[0] if flow.Attrs["name"] != "galaxy_message" { From a222df9db000e0a80ca723bbcbbc2fd43f4698b9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 02:47:18 +0300 Subject: [PATCH 146/163] fix: unwrap list business metadata --- send.go | 2 ++ send_test.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/send.go b/send.go index c58a6af21..790273ecd 100644 --- a/send.go +++ b/send.go @@ -1049,6 +1049,8 @@ func getButtonAttributes(msg *waE2E.Message) waBinary.Attrs { return getButtonAttributes(msg.ViewOnceMessage.Message) case msg.ViewOnceMessageV2 != nil: return getButtonAttributes(msg.ViewOnceMessageV2.Message) + case msg.ViewOnceMessageV2Extension != nil: + return getButtonAttributes(msg.ViewOnceMessageV2Extension.Message) case msg.EphemeralMessage != nil: return getButtonAttributes(msg.EphemeralMessage.Message) case msg.TemplateMessage != nil: diff --git a/send_test.go b/send_test.go index 1b87211a2..f0f0a2cc9 100644 --- a/send_test.go +++ b/send_test.go @@ -79,6 +79,16 @@ func TestNativeFlowBusinessMetadataUnwrapsMessages(t *testing.T) { } } +func TestListBusinessMetadataUnwrapsViewOnceV2Extension(t *testing.T) { + msg := &waE2E.Message{ViewOnceMessageV2Extension: &waE2E.FutureProofMessage{Message: &waE2E.Message{ + ListMessage: &waE2E.ListMessage{ListType: waE2E.ListMessage_SINGLE_SELECT.Enum()}, + }}} + attrs := getButtonAttributes(msg) + if attrs["v"] != "2" || attrs["type"] != "single_select" { + t.Fatalf("unexpected list metadata: %#v", attrs) + } +} + func TestSetParticipantHashMismatch(t *testing.T) { tests := []struct { name string From dce0461ddc072708685cc5ba23ea92f7d3d23635 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:04:39 +0300 Subject: [PATCH 147/163] fix: enforce address flow text limits --- business_message_builders.go | 2 +- business_message_builders_test.go | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/business_message_builders.go b/business_message_builders.go index 7cf1120dc..65311b4c4 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -337,7 +337,7 @@ func BuildBusinessNativeFlowButtonsMessage(params BusinessNativeFlowButtonsMessa } func BuildBusinessAddressMessage(params BusinessAddressMessageParams) (*waE2E.Message, error) { - if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 4096) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 256) || !bounded(params.Footer, 256) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 1024) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { return nil, errors.New("invalid business address message text") } buttonParams, err := json.Marshal(struct { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index d1b181a6c..e1faf6c26 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -255,6 +255,22 @@ func TestBuildBusinessAddressMessageMatchesWebGenerator(t *testing.T) { } } +func TestBusinessAddressMessageEnforcesInteractiveTextLimits(t *testing.T) { + valid := BusinessAddressMessageParams{Body: "Address", ButtonText: "Share", Footer: "Footer"} + tests := map[string]BusinessAddressMessageParams{ + "body": {Body: strings.Repeat("b", 1025), ButtonText: valid.ButtonText, Footer: valid.Footer}, + "button": {Body: valid.Body, ButtonText: strings.Repeat("c", 21), Footer: valid.Footer}, + "footer": {Body: valid.Body, ButtonText: valid.ButtonText, Footer: strings.Repeat("f", 61)}, + } + for name, params := range tests { + t.Run(name, func(t *testing.T) { + if _, err := BuildBusinessAddressMessage(params); err == nil { + t.Fatal("expected address text limit error") + } + }) + } +} + func TestBuildBusinessFlowMessageMatchesWebGenerator(t *testing.T) { msg, err := BuildBusinessFlowMessage(BusinessFlowMessageParams{ Body: "Book a visit", ButtonText: "Choose a time", FlowID: "flow-100", FlowToken: "synthetic-token", From 3389e2247fea4de2f5575c23e6e3c5c179147858 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 03:25:21 +0300 Subject: [PATCH 148/163] fix: enforce flow text limits --- business_message_builders.go | 2 +- business_message_builders_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/business_message_builders.go b/business_message_builders.go index 65311b4c4..6162bde94 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -350,7 +350,7 @@ func BuildBusinessAddressMessage(params BusinessAddressMessageParams) (*waE2E.Me } func BuildBusinessFlowMessage(params BusinessFlowMessageParams) (*waE2E.Message, error) { - if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 4096) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 256) || !bounded(params.Footer, 256) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 1024) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { return nil, errors.New("invalid business flow message text") } if strings.TrimSpace(params.FlowID) == "" || !bounded(params.FlowID, 256) || strings.TrimSpace(params.FlowToken) == "" || !bounded(params.FlowToken, 8192) { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index e1faf6c26..cfd21aa3a 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -271,6 +271,34 @@ func TestBusinessAddressMessageEnforcesInteractiveTextLimits(t *testing.T) { } } +func TestBusinessFlowMessageEnforcesInteractiveTextLimits(t *testing.T) { + valid := BusinessFlowMessageParams{ + Body: "Book a visit", ButtonText: "Choose a time", Footer: "Appointments", + FlowID: "flow-100", FlowToken: "synthetic-token", FlowAction: "navigate", Screen: "APPOINTMENT", + } + tests := map[string]BusinessFlowMessageParams{ + "body": { + Body: strings.Repeat("b", 1025), ButtonText: valid.ButtonText, Footer: valid.Footer, + FlowID: valid.FlowID, FlowToken: valid.FlowToken, FlowAction: valid.FlowAction, Screen: valid.Screen, + }, + "button": { + Body: valid.Body, ButtonText: strings.Repeat("c", 21), Footer: valid.Footer, + FlowID: valid.FlowID, FlowToken: valid.FlowToken, FlowAction: valid.FlowAction, Screen: valid.Screen, + }, + "footer": { + Body: valid.Body, ButtonText: valid.ButtonText, Footer: strings.Repeat("f", 61), + FlowID: valid.FlowID, FlowToken: valid.FlowToken, FlowAction: valid.FlowAction, Screen: valid.Screen, + }, + } + for name, params := range tests { + t.Run(name, func(t *testing.T) { + if _, err := BuildBusinessFlowMessage(params); err == nil { + t.Fatal("expected flow text limit error") + } + }) + } +} + func TestBuildBusinessFlowMessageMatchesWebGenerator(t *testing.T) { msg, err := BuildBusinessFlowMessage(BusinessFlowMessageParams{ Body: "Book a visit", ButtonText: "Choose a time", FlowID: "flow-100", FlowToken: "synthetic-token", From 3da127f9f6fe8cf6841e472026239b2541e757b3 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 06:08:18 +0300 Subject: [PATCH 149/163] fix: reject invalid flow button text --- business_message_builders.go | 5 +++-- business_message_builders_test.go | 11 ++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index 6162bde94..f12f0e9d6 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "strings" + "unicode/utf8" "google.golang.org/protobuf/proto" @@ -337,7 +338,7 @@ func BuildBusinessNativeFlowButtonsMessage(params BusinessNativeFlowButtonsMessa } func BuildBusinessAddressMessage(params BusinessAddressMessageParams) (*waE2E.Message, error) { - if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 1024) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 1024) || strings.TrimSpace(params.ButtonText) == "" || !utf8.ValidString(params.ButtonText) || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { return nil, errors.New("invalid business address message text") } buttonParams, err := json.Marshal(struct { @@ -350,7 +351,7 @@ func BuildBusinessAddressMessage(params BusinessAddressMessageParams) (*waE2E.Me } func BuildBusinessFlowMessage(params BusinessFlowMessageParams) (*waE2E.Message, error) { - if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 1024) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 1024) || strings.TrimSpace(params.ButtonText) == "" || !utf8.ValidString(params.ButtonText) || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { return nil, errors.New("invalid business flow message text") } if strings.TrimSpace(params.FlowID) == "" || !bounded(params.FlowID, 256) || strings.TrimSpace(params.FlowToken) == "" || !bounded(params.FlowToken, 8192) { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index cfd21aa3a..e23f3a6d1 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -258,9 +258,10 @@ func TestBuildBusinessAddressMessageMatchesWebGenerator(t *testing.T) { func TestBusinessAddressMessageEnforcesInteractiveTextLimits(t *testing.T) { valid := BusinessAddressMessageParams{Body: "Address", ButtonText: "Share", Footer: "Footer"} tests := map[string]BusinessAddressMessageParams{ - "body": {Body: strings.Repeat("b", 1025), ButtonText: valid.ButtonText, Footer: valid.Footer}, - "button": {Body: valid.Body, ButtonText: strings.Repeat("c", 21), Footer: valid.Footer}, - "footer": {Body: valid.Body, ButtonText: valid.ButtonText, Footer: strings.Repeat("f", 61)}, + "body": {Body: strings.Repeat("b", 1025), ButtonText: valid.ButtonText, Footer: valid.Footer}, + "button": {Body: valid.Body, ButtonText: strings.Repeat("c", 21), Footer: valid.Footer}, + "button-utf8": {Body: valid.Body, ButtonText: string([]byte{0xff}), Footer: valid.Footer}, + "footer": {Body: valid.Body, ButtonText: valid.ButtonText, Footer: strings.Repeat("f", 61)}, } for name, params := range tests { t.Run(name, func(t *testing.T) { @@ -285,6 +286,10 @@ func TestBusinessFlowMessageEnforcesInteractiveTextLimits(t *testing.T) { Body: valid.Body, ButtonText: strings.Repeat("c", 21), Footer: valid.Footer, FlowID: valid.FlowID, FlowToken: valid.FlowToken, FlowAction: valid.FlowAction, Screen: valid.Screen, }, + "button-utf8": { + Body: valid.Body, ButtonText: string([]byte{0xff}), Footer: valid.Footer, + FlowID: valid.FlowID, FlowToken: valid.FlowToken, FlowAction: valid.FlowAction, Screen: valid.Screen, + }, "footer": { Body: valid.Body, ButtonText: valid.ButtonText, Footer: strings.Repeat("f", 61), FlowID: valid.FlowID, FlowToken: valid.FlowToken, FlowAction: valid.FlowAction, Screen: valid.Screen, From c8ac2bcd5ce892ffad8860723f06f73463aa167b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 06:14:22 +0300 Subject: [PATCH 150/163] fix: preserve flow payload text --- business_message_builders.go | 6 +++--- business_message_builders_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index f12f0e9d6..e2f183f34 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -354,19 +354,19 @@ func BuildBusinessFlowMessage(params BusinessFlowMessageParams) (*waE2E.Message, if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 1024) || strings.TrimSpace(params.ButtonText) == "" || !utf8.ValidString(params.ButtonText) || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { return nil, errors.New("invalid business flow message text") } - if strings.TrimSpace(params.FlowID) == "" || !bounded(params.FlowID, 256) || strings.TrimSpace(params.FlowToken) == "" || !bounded(params.FlowToken, 8192) { + if strings.TrimSpace(params.FlowID) == "" || !utf8.ValidString(params.FlowID) || !bounded(params.FlowID, 256) || strings.TrimSpace(params.FlowToken) == "" || !utf8.ValidString(params.FlowToken) || !bounded(params.FlowToken, 8192) { return nil, errors.New("invalid business flow identity") } if params.FlowAction != "navigate" && params.FlowAction != "data_exchange" { return nil, errors.New("invalid business flow action") } - if !bounded(params.Screen, 256) || (params.FlowAction == "navigate" && strings.TrimSpace(params.Screen) == "") { + if !utf8.ValidString(params.Screen) || !bounded(params.Screen, 256) || (params.FlowAction == "navigate" && strings.TrimSpace(params.Screen) == "") { return nil, errors.New("invalid business flow screen") } if params.FlowAction == "data_exchange" && (params.Screen != "" || params.DataJSON != "") { return nil, errors.New("data-exchange flow messages cannot include an action payload") } - if !bounded(params.DataJSON, 16*1024) { + if !utf8.ValidString(params.DataJSON) || !bounded(params.DataJSON, 16*1024) { return nil, errors.New("business flow data is too large") } var data map[string]json.RawMessage diff --git a/business_message_builders_test.go b/business_message_builders_test.go index e23f3a6d1..e3bfcb595 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -304,6 +304,30 @@ func TestBusinessFlowMessageEnforcesInteractiveTextLimits(t *testing.T) { } } +func TestBusinessFlowMessageRejectsInvalidUTF8PayloadFields(t *testing.T) { + valid := BusinessFlowMessageParams{ + Body: "Book a visit", ButtonText: "Choose a time", FlowID: "flow-100", FlowToken: "synthetic-token", + FlowAction: "navigate", Screen: "APPOINTMENT", DataJSON: `{"location":"beirut"}`, + } + tests := map[string]func(*BusinessFlowMessageParams){ + "flow-id": func(params *BusinessFlowMessageParams) { params.FlowID = string([]byte{0xff}) }, + "flow-token": func(params *BusinessFlowMessageParams) { params.FlowToken = string([]byte{0xff}) }, + "screen": func(params *BusinessFlowMessageParams) { params.Screen = string([]byte{0xff}) }, + "data": func(params *BusinessFlowMessageParams) { + params.DataJSON = "{\"key\":\"" + string([]byte{0xff}) + "\"}" + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + params := valid + mutate(¶ms) + if _, err := BuildBusinessFlowMessage(params); err == nil { + t.Fatal("expected invalid UTF-8 error") + } + }) + } +} + func TestBuildBusinessFlowMessageMatchesWebGenerator(t *testing.T) { msg, err := BuildBusinessFlowMessage(BusinessFlowMessageParams{ Body: "Book a visit", ButtonText: "Choose a time", FlowID: "flow-100", FlowToken: "synthetic-token", From e6744f076760be1b8f3f89253e98b5cccdfae147 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 07:57:14 +0300 Subject: [PATCH 151/163] test: reject trailing flow JSON --- business_message_builders_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/business_message_builders_test.go b/business_message_builders_test.go index e3bfcb595..75f031163 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -423,6 +423,11 @@ func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { }); err == nil { t.Fatal("expected non-object flow data to fail") } + if _, err := BuildBusinessFlowMessage(BusinessFlowMessageParams{ + Body: "Flow", ButtonText: "Open", FlowID: "flow", FlowToken: "token", FlowAction: "navigate", Screen: "START", DataJSON: `{} {}`, + }); err == nil { + t.Fatal("expected trailing flow JSON to fail") + } } func TestBusinessProductListAndNativeFlowTextLimits(t *testing.T) { From 0c67c75cc7d99582a571d734fb2220d187e5b459 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 14:31:06 +0300 Subject: [PATCH 152/163] fix: preserve mixed native flow metadata --- send.go | 9 ++++++++- send_test.go | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/send.go b/send.go index 790273ecd..dc357ff35 100644 --- a/send.go +++ b/send.go @@ -1026,7 +1026,14 @@ func buildNativeFlowBizNode(msg *waE2E.Message, nowUnix int64) waBinary.Node { unwrapped: if interactive := msg.GetInteractiveMessage(); interactive != nil { if buttons := interactive.GetNativeFlowMessage().GetButtons(); len(buttons) > 0 && buttons[0].GetName() != "" { - name = buttons[0].GetName() + candidate := buttons[0].GetName() + name = candidate + for _, button := range buttons[1:] { + if button.GetName() != candidate { + name = "mixed" + break + } + } } } return waBinary.Node{ diff --git a/send_test.go b/send_test.go index f0f0a2cc9..054cece8c 100644 --- a/send_test.go +++ b/send_test.go @@ -53,6 +53,22 @@ func TestInteractiveNativeFlowsRequestNamedBusinessMetadata(t *testing.T) { } } +func TestHeterogeneousNativeFlowsRequestMixedBusinessMetadata(t *testing.T) { + msg := &waE2E.Message{InteractiveMessage: &waE2E.InteractiveMessage{ + InteractiveMessage: &waE2E.InteractiveMessage_NativeFlowMessage_{NativeFlowMessage: &waE2E.InteractiveMessage_NativeFlowMessage{ + Buttons: []*waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton{ + {Name: proto.String("quick_reply")}, + {Name: proto.String("cta_url")}, + }, + }}, + }} + biz := buildNativeFlowBizNode(msg, 1_700_000_000) + flow := biz.Content.([]waBinary.Node)[0].Content.([]waBinary.Node)[0] + if flow.Attrs["name"] != "mixed" { + t.Fatalf("native-flow name = %q", flow.Attrs["name"]) + } +} + func TestNativeFlowBusinessMetadataUnwrapsMessages(t *testing.T) { inner := &waE2E.Message{InteractiveMessage: &waE2E.InteractiveMessage{ InteractiveMessage: &waE2E.InteractiveMessage_NativeFlowMessage_{NativeFlowMessage: &waE2E.InteractiveMessage_NativeFlowMessage{ From 3a5780d0626dbf7bf614abe9046724df0ee84994 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 14:37:45 +0300 Subject: [PATCH 153/163] fix: preserve explicit empty flow data --- business_message_builders.go | 10 ++++++---- business_message_builders_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/business_message_builders.go b/business_message_builders.go index e2f183f34..2478ef5d3 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -369,15 +369,17 @@ func BuildBusinessFlowMessage(params BusinessFlowMessageParams) (*waE2E.Message, if !utf8.ValidString(params.DataJSON) || !bounded(params.DataJSON, 16*1024) { return nil, errors.New("business flow data is too large") } - var data map[string]json.RawMessage + var data *map[string]json.RawMessage if params.DataJSON != "" { - if err := json.Unmarshal([]byte(params.DataJSON), &data); err != nil || data == nil { + parsed := make(map[string]json.RawMessage) + if err := json.Unmarshal([]byte(params.DataJSON), &parsed); err != nil || parsed == nil { return nil, errors.New("business flow data must be a JSON object") } + data = &parsed } type actionPayload struct { - Screen string `json:"screen,omitempty"` - Data map[string]json.RawMessage `json:"data,omitempty"` + Screen string `json:"screen,omitempty"` + Data *map[string]json.RawMessage `json:"data,omitempty"` } var payload *actionPayload if params.FlowAction == "navigate" { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index 75f031163..d3cc3b012 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -364,6 +364,37 @@ func TestBuildBusinessFlowMessageMatchesWebGenerator(t *testing.T) { } } +func TestBuildBusinessFlowMessagePreservesExplicitEmptyData(t *testing.T) { + base := BusinessFlowMessageParams{ + Body: "Book a visit", ButtonText: "Choose a time", FlowID: "flow-100", FlowToken: "synthetic-token", + FlowAction: "navigate", Screen: "APPOINTMENT", + } + for name, dataJSON := range map[string]string{"omitted": "", "empty": `{}`} { + t.Run(name, func(t *testing.T) { + params := base + params.DataJSON = dataJSON + msg, err := BuildBusinessFlowMessage(params) + if err != nil { + t.Fatal(err) + } + var encoded struct { + ActionPayload map[string]json.RawMessage `json:"flow_action_payload"` + } + buttonJSON := msg.GetInteractiveMessage().GetNativeFlowMessage().GetButtons()[0].GetButtonParamsJSON() + if err := json.Unmarshal([]byte(buttonJSON), &encoded); err != nil { + t.Fatal(err) + } + data, present := encoded.ActionPayload["data"] + if dataJSON == "" && present { + t.Fatalf("omitted data encoded as %s", data) + } + if dataJSON != "" && (!present || string(data) != `{}`) { + t.Fatalf("explicit empty data encoded as %s", data) + } + }) + } +} + func TestBusinessMessageBuildersRejectUnsafeInputs(t *testing.T) { if _, err := BuildBusinessProductMessage(BusinessProductMessageParams{ProductID: "p", Title: "Tea", CurrencyCode: "USD"}); err == nil { t.Fatal("expected missing owner to fail") From b5e5ba37e07593ec608787733f0bc2664afe61cd Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 20:28:15 +0300 Subject: [PATCH 154/163] build: publish HyperMeow as its own Go module HyperMeow declared `module go.mau.fi/whatsmeow`, so consumers could only reach it through `replace go.mau.fi/whatsmeow => github.com/polymorfa/ hypermeow`. Go only honours a `replace` in the main module, so that arrangement never propagated: anything that depended in turn on a module using HyperMeow silently resolved upstream whatsmeow instead, and `go get github.com/polymorfa/hypermeow` could not work at all. Declare the module under its own path, `github.com/polymorfa/hypermeow`, and rewrite internal import paths to match. Package names are unchanged - the root package is still `whatsmeow` - so only import paths move and no call site changes. The 57 generated descriptors are regenerated rather than text-edited. The `go_package` option is stored in the raw descriptor behind two protobuf varint length prefixes, and the path grows by 11 bytes, so a substitution would have left every length prefix understating its payload and corrupted the descriptors at runtime. Regenerating with the toolchain the artifacts were produced by (protoc-gen-go v1.36.11) reproduces them byte-identically apart from the intended change; the only incidental diff is the recorded protoc patch version, 6.33.5 to 6.33.6. The nested benchmark module already carried the new path and now requires it directly. --- .pre-commit-config.yaml | 2 +- README.md | 20 ++++++++-- appstate.go | 14 +++---- appstate/decode.go | 10 ++--- appstate/encode.go | 10 ++--- appstate/encode_label_test.go | 2 +- appstate/hash.go | 6 +-- appstate/keys.go | 6 +-- appstate/lthash/lthash.go | 2 +- appstate/recovery.go | 8 ++-- appstate_label_events_test.go | 8 ++-- armadillomessage.go | 16 ++++---- benchmark/barback/Dockerfile | 2 +- benchmark/barback/Dockerfile.clientmem | 2 +- .../barback/cmd/bench/business_app_smoke.go | 4 +- .../cmd/bench/business_app_smoke_legacy.go | 2 +- benchmark/barback/cmd/bench/main.go | 12 +++--- benchmark/barback/cmd/bench/main_test.go | 8 ++-- .../barback/cmd/bench/phone_consent_sync.go | 2 +- .../cmd/bench/phone_consent_sync_legacy.go | 2 +- .../barback/cmd/bench/security_code_smoke.go | 6 +-- .../cmd/bench/security_code_smoke_legacy.go | 4 +- .../bench/security_code_smoke_legacy_test.go | 2 +- .../barback/cmd/bench/workload_messages.go | 6 +-- benchmark/barback/cmd/clientmem/main.go | 6 +-- benchmark/barback/go.mod | 4 +- binary/attrs.go | 2 +- binary/decoder.go | 4 +- binary/decoder_test.go | 4 +- binary/encoder.go | 4 +- binary/node.go | 2 +- binary/proto/doc.go | 2 +- binary/proto/legacy.go | 40 +++++++++---------- broadcast.go | 4 +- business.go | 4 +- business_account.go | 4 +- business_account_test.go | 4 +- business_catalog.go | 4 +- business_catalog_test.go | 4 +- business_collection_mutation.go | 2 +- business_collection_mutation_test.go | 2 +- business_commerce_control.go | 2 +- business_commerce_control_test.go | 2 +- business_merchant_compliance.go | 2 +- business_merchant_compliance_test.go | 6 +-- business_message_builders.go | 4 +- business_message_builders_test.go | 4 +- business_product_mutation.go | 6 +-- business_product_mutation_test.go | 4 +- business_profile.go | 6 +-- business_profile_test.go | 4 +- call.go | 6 +-- client.go | 22 +++++----- client_memory_test.go | 6 +-- client_test.go | 8 ++-- connectionevents.go | 8 ++-- cstoken.go | 2 +- download-to-file.go | 4 +- download.go | 16 ++++---- errors.go | 2 +- errors_iq_test.go | 2 +- go.mod | 2 +- group.go | 8 ++-- group_username_test.go | 4 +- handshake.go | 8 ++-- historysync_test.go | 8 ++-- internals.go | 28 ++++++------- internals_generate.go | 2 +- keepalive.go | 4 +- lid_resolution_test.go | 6 +-- mediaconn.go | 4 +- mediaretry.go | 12 +++--- message.go | 18 ++++----- message_name_updates_test.go | 4 +- msgsecret.go | 12 +++--- newsletter.go | 8 ++-- newsletter_delete.go | 4 +- newsletter_delete_test.go | 6 +-- notification.go | 12 +++--- notification_device_test.go | 8 ++-- notification_identity_test.go | 8 ++-- pair-code.go | 8 ++-- pair-passkey.go | 16 ++++---- pair.go | 16 ++++---- phone_number_message.go | 2 +- phone_number_message_test.go | 2 +- prekeys.go | 6 +-- presence.go | 6 +-- privacysettings.go | 6 +-- privacysettings_test.go | 2 +- proto/armadilloutil/decode.go | 2 +- proto/extra.go | 14 +++---- .../InstamadilloAddMessage.pb.go | 20 +++++----- .../InstamadilloAddMessage.proto | 2 +- .../InstamadilloCoreTypeActionLog.pb.go | 6 +-- .../InstamadilloCoreTypeActionLog.proto | 2 +- .../InstamadilloCoreTypeAdminMessage.pb.go | 6 +-- .../InstamadilloCoreTypeAdminMessage.proto | 2 +- .../InstamadilloCoreTypeCollection.pb.go | 8 ++-- .../InstamadilloCoreTypeCollection.proto | 2 +- .../InstamadilloCoreTypeLink.pb.go | 8 ++-- .../InstamadilloCoreTypeLink.proto | 2 +- .../InstamadilloCoreTypeMedia.pb.go | 6 +-- .../InstamadilloCoreTypeMedia.proto | 2 +- .../InstamadilloCoreTypeText.pb.go | 8 ++-- .../InstamadilloCoreTypeText.proto | 2 +- .../InstamadilloDeleteMessage.pb.go | 6 +-- .../InstamadilloDeleteMessage.proto | 2 +- .../InstamadilloSupplementMessage.pb.go | 8 ++-- .../InstamadilloSupplementMessage.proto | 2 +- .../InstamadilloTransportPayload.pb.go | 12 +++--- .../InstamadilloTransportPayload.proto | 2 +- .../InstamadilloXmaContentRef.pb.go | 6 +-- .../InstamadilloXmaContentRef.proto | 2 +- proto/waAICommon/WAWebProtobufsAICommon.pb.go | 6 +-- proto/waAICommon/WAWebProtobufsAICommon.proto | 2 +- .../WAAICommonDeprecated.pb.go | 4 +- .../WAAICommonDeprecated.proto | 2 +- proto/waAdv/WAAdv.pb.go | 6 +-- proto/waAdv/WAAdv.proto | 2 +- proto/waAea/WAWebProtobufsAea.pb.go | 4 +- proto/waAea/WAWebProtobufsAea.proto | 2 +- .../WAArmadilloApplication.pb.go | 10 ++--- .../WAArmadilloApplication.proto | 2 +- proto/waArmadilloApplication/extra.go | 6 +-- .../WAArmadilloBackupCommon.pb.go | 4 +- .../WAArmadilloBackupCommon.proto | 2 +- .../WAArmadilloBackupMessage.pb.go | 6 +-- .../WAArmadilloBackupMessage.proto | 2 +- proto/waArmadilloICDC/WAArmadilloICDC.pb.go | 6 +-- proto/waArmadilloICDC/WAArmadilloICDC.proto | 2 +- .../WAArmadilloMiTransportAdminMessage.pb.go | 4 +- .../WAArmadilloMiTransportAdminMessage.proto | 2 +- .../WAArmadilloTransportEvent.pb.go | 6 +-- .../WAArmadilloTransportEvent.proto | 2 +- proto/waArmadilloXMA/WAArmadilloXMA.pb.go | 6 +-- proto/waArmadilloXMA/WAArmadilloXMA.proto | 2 +- proto/waBotMetadata/WABotMetadata.pb.go | 8 ++-- proto/waBotMetadata/WABotMetadata.proto | 2 +- proto/waCert/WACert.pb.go | 6 +-- proto/waCert/WACert.proto | 2 +- .../WAWebProtobufsChatLockSettings.pb.go | 8 ++-- .../WAWebProtobufsChatLockSettings.proto | 2 +- proto/waCommon/WACommon.pb.go | 4 +- proto/waCommon/WACommon.proto | 2 +- .../WACommonParameterised.pb.go | 6 +-- .../WACommonParameterised.proto | 2 +- proto/waCompanionReg/WACompanionReg.pb.go | 4 +- proto/waCompanionReg/WACompanionReg.proto | 2 +- .../WAConsumerApplication.pb.go | 8 ++-- .../WAConsumerApplication.proto | 2 +- proto/waConsumerApplication/extra.go | 4 +- .../WAConsumerApplicationParameterised.pb.go | 8 ++-- .../WAConsumerApplicationParameterised.proto | 2 +- .../WAWebProtobufsDeviceCapabilities.pb.go | 4 +- .../WAWebProtobufsDeviceCapabilities.proto | 2 +- proto/waE2E/WAWebProtobufsE2E.pb.go | 22 +++++----- proto/waE2E/WAWebProtobufsE2E.proto | 2 +- proto/waE2EGuest/WAWebProtobufsE2EGuest.pb.go | 6 +-- proto/waE2EGuest/WAWebProtobufsE2EGuest.proto | 2 +- .../waEphemeral/WAWebProtobufsEphemeral.pb.go | 6 +-- .../waEphemeral/WAWebProtobufsEphemeral.proto | 2 +- proto/waFingerprint/WAFingerprint.pb.go | 6 +-- proto/waFingerprint/WAFingerprint.proto | 2 +- .../WAWebProtobufsGroupHistory.pb.go | 10 ++--- .../WAWebProtobufsGroupHistory.proto | 2 +- .../WAWebProtobufsHistorySync.pb.go | 14 +++---- .../WAWebProtobufsHistorySync.proto | 2 +- ...WAWebProtobufLidMigrationSyncPayload.pb.go | 6 +-- ...WAWebProtobufLidMigrationSyncPayload.proto | 2 +- proto/waMediaEntryData/WAMediaEntryData.pb.go | 6 +-- proto/waMediaEntryData/WAMediaEntryData.proto | 2 +- proto/waMediaTransport/WAMediaTransport.pb.go | 8 ++-- proto/waMediaTransport/WAMediaTransport.proto | 2 +- proto/waMmsRetry/WAMmsRetry.pb.go | 6 +-- proto/waMmsRetry/WAMmsRetry.proto | 2 +- proto/waMsgApplication/WAMsgApplication.pb.go | 8 ++-- proto/waMsgApplication/WAMsgApplication.proto | 2 +- proto/waMsgApplication/extra.go | 8 ++-- proto/waMsgTransport/WAMsgTransport.pb.go | 8 ++-- proto/waMsgTransport/WAMsgTransport.proto | 2 +- proto/waMsgTransport/extra.go | 6 +-- proto/waMultiDevice/WAMultiDevice.pb.go | 6 +-- proto/waMultiDevice/WAMultiDevice.proto | 2 +- ...WAWebProtobufsQuickPromotionSurfaces.pb.go | 6 +-- ...WAWebProtobufsQuickPromotionSurfaces.proto | 2 +- .../waReporting/WAWebProtobufsReporting.pb.go | 6 +-- .../waReporting/WAWebProtobufsReporting.proto | 2 +- .../WAWebProtobufsRoutingInfo.pb.go | 6 +-- .../WAWebProtobufsRoutingInfo.proto | 2 +- .../WAWebProtobufsServerSync.pb.go | 4 +- .../WAWebProtobufsServerSync.proto | 2 +- .../WAStatusAttributions.pb.go | 4 +- .../WAStatusAttributions.proto | 2 +- .../WAWebProtobufSyncAction.pb.go | 10 ++--- .../WAWebProtobufSyncAction.proto | 2 +- .../WAWebProtobufsSyncdSnapshotRecovery.pb.go | 8 ++-- .../WAWebProtobufsSyncdSnapshotRecovery.proto | 2 +- .../WAWebProtobufsUserPassword.pb.go | 6 +-- .../WAWebProtobufsUserPassword.proto | 2 +- .../waVnameCert/WAWebProtobufsVnameCert.pb.go | 6 +-- .../waVnameCert/WAWebProtobufsVnameCert.proto | 2 +- proto/waWa6/WAWebProtobufsWa6.pb.go | 4 +- proto/waWa6/WAWebProtobufsWa6.proto | 2 +- proto/waWeb/WAWebProtobufsWeb.pb.go | 8 ++-- proto/waWeb/WAWebProtobufsWeb.proto | 2 +- .../WAWebLabyrinthWaWasm.pb.go | 4 +- .../WAWebLabyrinthWaWasm.proto | 2 +- proto/waWinUIApi/WAWinUIApi.pb.go | 6 +-- proto/waWinUIApi/WAWinUIApi.proto | 2 +- push.go | 4 +- qrchan.go | 4 +- receipt.go | 6 +-- receipt_test.go | 2 +- reportingtoken.go | 6 +-- request.go | 4 +- retry.go | 16 ++++---- retry_test.go | 4 +- security_code.go | 6 +-- security_code_test.go | 6 +-- send.go | 12 +++--- send_test.go | 4 +- sendfb.go | 18 ++++----- socket/constants.go | 2 +- socket/framesocket.go | 2 +- socket/noisehandshake.go | 2 +- store/clientpayload.go | 6 +-- store/contact_test.go | 2 +- store/noop.go | 4 +- store/sessioncache_test.go | 2 +- store/sqlstore/container.go | 12 +++--- store/sqlstore/identity_reader_test.go | 2 +- store/sqlstore/lidmap.go | 4 +- store/sqlstore/lidmap_test.go | 2 +- store/sqlstore/store.go | 6 +-- store/sqlstore/store_test.go | 4 +- store/store.go | 8 ++-- store/store_test.go | 2 +- tctoken.go | 4 +- types/events/appstate.go | 6 +-- types/events/call.go | 4 +- types/events/events.go | 24 +++++------ types/newsletter.go | 2 +- types/user.go | 2 +- update.go | 4 +- upload.go | 4 +- user.go | 12 +++--- username_contact_test.go | 10 ++--- username_persistence_test.go | 8 ++-- username_resolution_test.go | 6 +-- 250 files changed, 702 insertions(+), 690 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 168c941d2..aa389af8a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: go-imports-repo args: - "-local" - - "go.mau.fi/whatsmeow" + - "github.com/polymorfa/hypermeow" - "-w" - id: go-vet-repo-mod # TODO enable this diff --git a/README.md b/README.md index e800ea4cc..13f8fae4d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,21 @@ # HyperMeow -[![Go Reference](https://pkg.go.dev/badge/github.com/polymorfa/whatsmeow.svg)](https://pkg.go.dev/github.com/polymorfa/whatsmeow) +[![Go Reference](https://pkg.go.dev/badge/github.com/polymorfa/hypermeow.svg)](https://pkg.go.dev/github.com/polymorfa/hypermeow) HyperMeow is a library used at Polymorfa to ship WhatsApp at scale. We forked from tulir's project since these performance changes are somewhat experimental and diverge from tulir's minimalist philosophy. For Polymorfa to succeed, we needed all the WhatsApp Web functions in one place, meanwhile tulir prefers the core functionalities / messaging be the scope of whatsmeow. -The module path and package names remain `go.mau.fi/whatsmeow` for compatibility. Consumers can test HyperMeow with a Go module `replace` directive while the fork is validated against upstream behavior. +HyperMeow is its own Go module, imported directly as `github.com/polymorfa/hypermeow`. It no longer requires a `replace` directive: + +```go +import whatsmeow "github.com/polymorfa/hypermeow" +``` + +```sh +go get github.com/polymorfa/hypermeow +``` + +Package names are unchanged from upstream (the root package is still `whatsmeow`), so only import paths differ. Import the root package under its package name, as above, when your tooling expects the path's last element to match. + +Migrating from the previous `replace go.mau.fi/whatsmeow => github.com/polymorfa/hypermeow` setup: drop the `replace` line, add a normal `require` on `github.com/polymorfa/hypermeow`, and rewrite `go.mau.fi/whatsmeow` import paths to `github.com/polymorfa/hypermeow`. A `replace` directive is only honoured in the main module, so the previous arrangement did not carry to anything that depended on your module in turn; a direct requirement does. The reproducible Barback and PostgreSQL benchmark is documented in [`benchmark/barback`](benchmark/barback/README.md). @@ -13,8 +25,8 @@ Discord server (#hypermeow channel): https://whiskey.so/discord ## Usage -The [godoc](https://pkg.go.dev/github.com/polymorfa/whatsmeow) includes docs for all methods and event types. -There's also a [simple example](https://pkg.go.dev/github.com/polymorfa/whatsmeow#example-package) at the top. +The [godoc](https://pkg.go.dev/github.com/polymorfa/hypermeow) includes docs for all methods and event types. +There's also a [simple example](https://pkg.go.dev/github.com/polymorfa/hypermeow#example-package) at the top. ## Features diff --git a/appstate.go b/appstate.go index 2da6a3726..6e870c331 100644 --- a/appstate.go +++ b/appstate.go @@ -17,13 +17,13 @@ import ( "go.mau.fi/util/exslices" "go.mau.fi/util/ptr" - "go.mau.fi/whatsmeow/appstate" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/proto/waServerSync" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + "github.com/polymorfa/hypermeow/appstate" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) // FetchAppState fetches updates to the given type of app state. If fullSync is true, the current diff --git a/appstate/decode.go b/appstate/decode.go index 41dc2bee4..dacda378f 100644 --- a/appstate/decode.go +++ b/appstate/decode.go @@ -17,11 +17,11 @@ import ( "google.golang.org/protobuf/proto" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waServerSync" - "go.mau.fi/whatsmeow/proto/waSyncAction" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/util/cbcutil" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/proto/waSyncAction" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/util/cbcutil" ) // PatchList represents a decoded response to getting app state patches from the WhatsApp servers. diff --git a/appstate/encode.go b/appstate/encode.go index bf9946963..490643c28 100644 --- a/appstate/encode.go +++ b/appstate/encode.go @@ -9,11 +9,11 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waServerSync" - "go.mau.fi/whatsmeow/proto/waSyncAction" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/util/cbcutil" + "github.com/polymorfa/hypermeow/proto/waCommon" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/proto/waSyncAction" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/util/cbcutil" ) // MutationInfo contains information about a single mutation to the app state. diff --git a/appstate/encode_label_test.go b/appstate/encode_label_test.go index 330bc7f21..8878b0822 100644 --- a/appstate/encode_label_test.go +++ b/appstate/encode_label_test.go @@ -3,7 +3,7 @@ package appstate import ( "testing" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) func TestBuildLabelChatChangesUsesOnePatch(t *testing.T) { diff --git a/appstate/hash.go b/appstate/hash.go index a3070bff4..db7700ca9 100644 --- a/appstate/hash.go +++ b/appstate/hash.go @@ -14,9 +14,9 @@ import ( "fmt" "hash" - "go.mau.fi/whatsmeow/appstate/lthash" - "go.mau.fi/whatsmeow/proto/waServerSync" - "go.mau.fi/whatsmeow/proto/waSyncAction" + "github.com/polymorfa/hypermeow/appstate/lthash" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/proto/waSyncAction" ) type Mutation struct { diff --git a/appstate/keys.go b/appstate/keys.go index 97a2fdad1..04e69f4e9 100644 --- a/appstate/keys.go +++ b/appstate/keys.go @@ -12,9 +12,9 @@ import ( "encoding/base64" "sync" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/util/hkdfutil" - waLog "go.mau.fi/whatsmeow/util/log" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/util/hkdfutil" + waLog "github.com/polymorfa/hypermeow/util/log" ) // WAPatchName represents a type of app state patch. diff --git a/appstate/lthash/lthash.go b/appstate/lthash/lthash.go index 8d3045d60..2cad11b3d 100644 --- a/appstate/lthash/lthash.go +++ b/appstate/lthash/lthash.go @@ -13,7 +13,7 @@ package lthash import ( "encoding/binary" - "go.mau.fi/whatsmeow/util/hkdfutil" + "github.com/polymorfa/hypermeow/util/hkdfutil" ) type LTHash struct { diff --git a/appstate/recovery.go b/appstate/recovery.go index 40946c31c..a976b3796 100644 --- a/appstate/recovery.go +++ b/appstate/recovery.go @@ -17,10 +17,10 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/proto/waServerSync" - "go.mau.fi/whatsmeow/proto/waSyncdSnapshotRecovery" - "go.mau.fi/whatsmeow/store" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/proto/waSyncdSnapshotRecovery" + "github.com/polymorfa/hypermeow/store" ) func ParseRecovery( diff --git a/appstate_label_events_test.go b/appstate_label_events_test.go index 7ec7debe7..057859a8a 100644 --- a/appstate_label_events_test.go +++ b/appstate_label_events_test.go @@ -7,10 +7,10 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/appstate" - "go.mau.fi/whatsmeow/proto/waServerSync" - "go.mau.fi/whatsmeow/proto/waSyncAction" - "go.mau.fi/whatsmeow/types/events" + "github.com/polymorfa/hypermeow/appstate" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/proto/waSyncAction" + "github.com/polymorfa/hypermeow/types/events" ) func TestSelectiveFullSyncLabelEvents(t *testing.T) { diff --git a/armadillomessage.go b/armadillomessage.go index aea351857..090071962 100644 --- a/armadillomessage.go +++ b/armadillomessage.go @@ -12,14 +12,14 @@ import ( "google.golang.org/protobuf/proto" - armadillo "go.mau.fi/whatsmeow/proto" - "go.mau.fi/whatsmeow/proto/armadilloutil" - "go.mau.fi/whatsmeow/proto/instamadilloTransportPayload" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waMsgApplication" - "go.mau.fi/whatsmeow/proto/waMsgTransport" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + armadillo "github.com/polymorfa/hypermeow/proto" + "github.com/polymorfa/hypermeow/proto/armadilloutil" + "github.com/polymorfa/hypermeow/proto/instamadilloTransportPayload" + "github.com/polymorfa/hypermeow/proto/waCommon" + "github.com/polymorfa/hypermeow/proto/waMsgApplication" + "github.com/polymorfa/hypermeow/proto/waMsgTransport" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) func (cli *Client) handleDecryptedArmadillo(ctx context.Context, info *types.MessageInfo, decrypted []byte, retryCount int) (handlerFailed, protobufFailed bool) { diff --git a/benchmark/barback/Dockerfile b/benchmark/barback/Dockerfile index b727330fe..218d51fd5 100644 --- a/benchmark/barback/Dockerfile +++ b/benchmark/barback/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /src COPY . . COPY --from=library . /library WORKDIR /src/benchmark/barback -RUN go mod edit -replace=go.mau.fi/whatsmeow=/library +RUN go mod edit -replace=github.com/polymorfa/hypermeow=/library RUN --mount=type=cache,target=/go/pkg/mod go mod tidy RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -tags="${BENCH_BUILD_TAGS}" -trimpath -ldflags="-s -w -X main.revision=${BUILD_REV}" -o /out/hypermeow-bench ./cmd/bench diff --git a/benchmark/barback/Dockerfile.clientmem b/benchmark/barback/Dockerfile.clientmem index de80d9970..5e2d140ac 100644 --- a/benchmark/barback/Dockerfile.clientmem +++ b/benchmark/barback/Dockerfile.clientmem @@ -6,7 +6,7 @@ WORKDIR /src COPY . . COPY --from=library . /library WORKDIR /src/benchmark/barback -RUN go mod edit -replace=go.mau.fi/whatsmeow=/library +RUN go mod edit -replace=github.com/polymorfa/hypermeow=/library RUN --mount=type=cache,target=/go/pkg/mod go mod tidy RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.revision=${BUILD_REV}" -o /out/clientmem ./cmd/clientmem diff --git a/benchmark/barback/cmd/bench/business_app_smoke.go b/benchmark/barback/cmd/bench/business_app_smoke.go index 889632905..6e1e23a2f 100644 --- a/benchmark/barback/cmd/bench/business_app_smoke.go +++ b/benchmark/barback/cmd/bench/business_app_smoke.go @@ -6,8 +6,8 @@ import ( "context" "fmt" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/types" + whatsmeow "github.com/polymorfa/hypermeow" + "github.com/polymorfa/hypermeow/types" ) func businessAppSmokeSupported() bool { diff --git a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go index 8ad4360a4..4822a6b7e 100644 --- a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go +++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go @@ -5,7 +5,7 @@ package main import ( "context" - "go.mau.fi/whatsmeow" + whatsmeow "github.com/polymorfa/hypermeow" ) func businessAppSmokeSupported() bool { diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index 7da923ca9..d3a267774 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -28,12 +28,12 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/store/sqlstore" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - waLog "go.mau.fi/whatsmeow/util/log" + whatsmeow "github.com/polymorfa/hypermeow" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/store/sqlstore" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + waLog "github.com/polymorfa/hypermeow/util/log" ) var revision = "working-tree" diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index 0daa16cf0..cc01df272 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -12,10 +12,10 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + whatsmeow "github.com/polymorfa/hypermeow" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) func TestLoadConfigWorkload(t *testing.T) { diff --git a/benchmark/barback/cmd/bench/phone_consent_sync.go b/benchmark/barback/cmd/bench/phone_consent_sync.go index f34946c26..ac6a1eafb 100644 --- a/benchmark/barback/cmd/bench/phone_consent_sync.go +++ b/benchmark/barback/cmd/bench/phone_consent_sync.go @@ -2,7 +2,7 @@ package main -import "go.mau.fi/whatsmeow" +import whatsmeow "github.com/polymorfa/hypermeow" func enablePhoneConsentReceiveBarrier(client *whatsmeow.Client) { client.DangerousInternals().SetSynchronousMessageNameUpdates(true) diff --git a/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go b/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go index eded51d14..3ed971349 100644 --- a/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go +++ b/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go @@ -2,6 +2,6 @@ package main -import "go.mau.fi/whatsmeow" +import whatsmeow "github.com/polymorfa/hypermeow" func enablePhoneConsentReceiveBarrier(_ *whatsmeow.Client) {} diff --git a/benchmark/barback/cmd/bench/security_code_smoke.go b/benchmark/barback/cmd/bench/security_code_smoke.go index 35398f357..50f1866c7 100644 --- a/benchmark/barback/cmd/bench/security_code_smoke.go +++ b/benchmark/barback/cmd/bench/security_code_smoke.go @@ -9,9 +9,9 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waFingerprint" - "go.mau.fi/whatsmeow/types" + whatsmeow "github.com/polymorfa/hypermeow" + "github.com/polymorfa/hypermeow/proto/waFingerprint" + "github.com/polymorfa/hypermeow/types" ) func validateIdentityVerificationCodes(ctx context.Context, client *whatsmeow.Client, userID types.JID) error { diff --git a/benchmark/barback/cmd/bench/security_code_smoke_legacy.go b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go index 0fbd6de63..437d488be 100644 --- a/benchmark/barback/cmd/bench/security_code_smoke_legacy.go +++ b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go @@ -5,8 +5,8 @@ package main import ( "context" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/types" + whatsmeow "github.com/polymorfa/hypermeow" + "github.com/polymorfa/hypermeow/types" ) func validateIdentityVerificationCodes(context.Context, *whatsmeow.Client, types.JID) error { diff --git a/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go b/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go index 433d146d0..8ba5fd4aa 100644 --- a/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go +++ b/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go @@ -6,7 +6,7 @@ import ( "context" "testing" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) func TestLegacySecurityCodeValidationIsSkipped(t *testing.T) { diff --git a/benchmark/barback/cmd/bench/workload_messages.go b/benchmark/barback/cmd/bench/workload_messages.go index 43183f733..44208ac4d 100644 --- a/benchmark/barback/cmd/bench/workload_messages.go +++ b/benchmark/barback/cmd/bench/workload_messages.go @@ -9,9 +9,9 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waE2E" + whatsmeow "github.com/polymorfa/hypermeow" + "github.com/polymorfa/hypermeow/proto/waCommon" + "github.com/polymorfa/hypermeow/proto/waE2E" ) var ( diff --git a/benchmark/barback/cmd/clientmem/main.go b/benchmark/barback/cmd/clientmem/main.go index ed6e2beb1..fc0a7ed3e 100644 --- a/benchmark/barback/cmd/clientmem/main.go +++ b/benchmark/barback/cmd/clientmem/main.go @@ -9,9 +9,9 @@ import ( "strconv" "syscall" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/store" - waLog "go.mau.fi/whatsmeow/util/log" + whatsmeow "github.com/polymorfa/hypermeow" + "github.com/polymorfa/hypermeow/store" + waLog "github.com/polymorfa/hypermeow/util/log" ) var revision = "working-tree" diff --git a/benchmark/barback/go.mod b/benchmark/barback/go.mod index 565e97c84..6e26db10c 100644 --- a/benchmark/barback/go.mod +++ b/benchmark/barback/go.mod @@ -6,7 +6,7 @@ toolchain go1.26.5 require ( github.com/jackc/pgx/v5 v5.10.0 - go.mau.fi/whatsmeow v0.0.0 + github.com/polymorfa/hypermeow v0.0.0 google.golang.org/protobuf v1.36.11 ) @@ -31,4 +31,4 @@ require ( golang.org/x/text v0.40.0 // indirect ) -replace go.mau.fi/whatsmeow => ../.. +replace github.com/polymorfa/hypermeow => ../.. diff --git a/binary/attrs.go b/binary/attrs.go index 24880e567..1574ddc8c 100644 --- a/binary/attrs.go +++ b/binary/attrs.go @@ -11,7 +11,7 @@ import ( "strconv" "time" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) // AttrUtility is a helper struct for reading multiple XML attributes and checking for errors afterwards. diff --git a/binary/decoder.go b/binary/decoder.go index 436e19772..62ad421eb 100644 --- a/binary/decoder.go +++ b/binary/decoder.go @@ -6,8 +6,8 @@ import ( "io" "strings" - "go.mau.fi/whatsmeow/binary/token" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/binary/token" + "github.com/polymorfa/hypermeow/types" ) type binaryDecoder struct { diff --git a/binary/decoder_test.go b/binary/decoder_test.go index 16370c7c0..8377e374c 100644 --- a/binary/decoder_test.go +++ b/binary/decoder_test.go @@ -4,8 +4,8 @@ import ( "reflect" "testing" - "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) func TestMarshalUnmarshalRoundTrip(t *testing.T) { diff --git a/binary/encoder.go b/binary/encoder.go index 3b2b014a3..0e35b1cfb 100644 --- a/binary/encoder.go +++ b/binary/encoder.go @@ -5,8 +5,8 @@ import ( "math" "strconv" - "go.mau.fi/whatsmeow/binary/token" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/binary/token" + "github.com/polymorfa/hypermeow/types" ) type binaryEncoder struct { diff --git a/binary/node.go b/binary/node.go index b421f8a59..0697d9b0c 100644 --- a/binary/node.go +++ b/binary/node.go @@ -11,7 +11,7 @@ import ( "encoding/json" "fmt" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) // Attrs is a type alias for the attributes of an XML element (Node). diff --git a/binary/proto/doc.go b/binary/proto/doc.go index 6ebecb93f..9f6396063 100644 --- a/binary/proto/doc.go +++ b/binary/proto/doc.go @@ -1,4 +1,4 @@ // Package proto contains type aliases for backwards compatibility. // -// Deprecated: New code should reference the protobuf types in the go.mau.fi/whatsmeow/proto/wa* packages directly. +// Deprecated: New code should reference the protobuf types in the github.com/polymorfa/hypermeow/proto/wa* packages directly. package proto diff --git a/binary/proto/legacy.go b/binary/proto/legacy.go index cf9177eeb..5b0fc1a1a 100644 --- a/binary/proto/legacy.go +++ b/binary/proto/legacy.go @@ -3,26 +3,26 @@ package proto import ( - "go.mau.fi/whatsmeow/proto/waAICommon" - "go.mau.fi/whatsmeow/proto/waAdv" - "go.mau.fi/whatsmeow/proto/waBotMetadata" - "go.mau.fi/whatsmeow/proto/waCert" - "go.mau.fi/whatsmeow/proto/waChatLockSettings" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waCompanionReg" - "go.mau.fi/whatsmeow/proto/waDeviceCapabilities" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/proto/waEphemeral" - "go.mau.fi/whatsmeow/proto/waHistorySync" - "go.mau.fi/whatsmeow/proto/waMmsRetry" - "go.mau.fi/whatsmeow/proto/waMsgTransport" - "go.mau.fi/whatsmeow/proto/waQuickPromotionSurfaces" - "go.mau.fi/whatsmeow/proto/waServerSync" - "go.mau.fi/whatsmeow/proto/waSyncAction" - "go.mau.fi/whatsmeow/proto/waUserPassword" - "go.mau.fi/whatsmeow/proto/waVnameCert" - "go.mau.fi/whatsmeow/proto/waWa6" - "go.mau.fi/whatsmeow/proto/waWeb" + "github.com/polymorfa/hypermeow/proto/waAICommon" + "github.com/polymorfa/hypermeow/proto/waAdv" + "github.com/polymorfa/hypermeow/proto/waBotMetadata" + "github.com/polymorfa/hypermeow/proto/waCert" + "github.com/polymorfa/hypermeow/proto/waChatLockSettings" + "github.com/polymorfa/hypermeow/proto/waCommon" + "github.com/polymorfa/hypermeow/proto/waCompanionReg" + "github.com/polymorfa/hypermeow/proto/waDeviceCapabilities" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waEphemeral" + "github.com/polymorfa/hypermeow/proto/waHistorySync" + "github.com/polymorfa/hypermeow/proto/waMmsRetry" + "github.com/polymorfa/hypermeow/proto/waMsgTransport" + "github.com/polymorfa/hypermeow/proto/waQuickPromotionSurfaces" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/proto/waSyncAction" + "github.com/polymorfa/hypermeow/proto/waUserPassword" + "github.com/polymorfa/hypermeow/proto/waVnameCert" + "github.com/polymorfa/hypermeow/proto/waWa6" + "github.com/polymorfa/hypermeow/proto/waWeb" ) // Deprecated: use new packages directly diff --git a/broadcast.go b/broadcast.go index d3768297d..1855f97c2 100644 --- a/broadcast.go +++ b/broadcast.go @@ -11,8 +11,8 @@ import ( "errors" "fmt" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) func (cli *Client) getBroadcastListParticipants(ctx context.Context, jid types.JID) ([]types.JID, error) { diff --git a/business.go b/business.go index d3cd4e7ed..ce8b18e72 100644 --- a/business.go +++ b/business.go @@ -12,8 +12,8 @@ import ( "strconv" "strings" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) // GetOrderDetails fetches the details of a specific order using its ID and token. diff --git a/business_account.go b/business_account.go index 234df6115..a841311db 100644 --- a/business_account.go +++ b/business_account.go @@ -5,8 +5,8 @@ import ( "fmt" "strconv" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) const ( diff --git a/business_account_test.go b/business_account_test.go index 3e9bb922e..ec77a62a4 100644 --- a/business_account_test.go +++ b/business_account_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) func TestBusinessLinkedAccountsQuery(t *testing.T) { diff --git a/business_catalog.go b/business_catalog.go index 409b910cc..8b031299a 100644 --- a/business_catalog.go +++ b/business_catalog.go @@ -7,8 +7,8 @@ import ( "strconv" "strings" - "go.mau.fi/whatsmeow/mex" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/mex" + "github.com/polymorfa/hypermeow/types" ) type GetCatalogParams struct { diff --git a/business_catalog_test.go b/business_catalog_test.go index 213b17c2c..d0087b4aa 100644 --- a/business_catalog_test.go +++ b/business_catalog_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) func TestBuildCatalogVariablesRejectsInvalidInput(t *testing.T) { diff --git a/business_collection_mutation.go b/business_collection_mutation.go index 3a734b2b7..c5cb16533 100644 --- a/business_collection_mutation.go +++ b/business_collection_mutation.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) const ( diff --git a/business_collection_mutation_test.go b/business_collection_mutation_test.go index 6408eb159..3de02d5de 100644 --- a/business_collection_mutation_test.go +++ b/business_collection_mutation_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) func TestBuildCreateBusinessCollectionVariables(t *testing.T) { diff --git a/business_commerce_control.go b/business_commerce_control.go index e899094dc..832e8a5fa 100644 --- a/business_commerce_control.go +++ b/business_commerce_control.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) const ( diff --git a/business_commerce_control_test.go b/business_commerce_control_test.go index 778e4ba6b..c8dfc4fda 100644 --- a/business_commerce_control_test.go +++ b/business_commerce_control_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) func TestBuildBusinessCommerceControlVariables(t *testing.T) { diff --git a/business_merchant_compliance.go b/business_merchant_compliance.go index 1ad0bc91e..e93de3594 100644 --- a/business_merchant_compliance.go +++ b/business_merchant_compliance.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) const ( diff --git a/business_merchant_compliance_test.go b/business_merchant_compliance_test.go index 0e5abbb68..eef660783 100644 --- a/business_merchant_compliance_test.go +++ b/business_merchant_compliance_test.go @@ -11,9 +11,9 @@ import ( "strings" "testing" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - waLog "go.mau.fi/whatsmeow/util/log" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + waLog "github.com/polymorfa/hypermeow/util/log" ) type merchantComplianceRoundTripper func(*http.Request) (*http.Response, error) diff --git a/business_message_builders.go b/business_message_builders.go index 2478ef5d3..723b99342 100644 --- a/business_message_builders.go +++ b/business_message_builders.go @@ -10,8 +10,8 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/types" ) type BusinessProductMessageParams struct { diff --git a/business_message_builders_test.go b/business_message_builders_test.go index d3cc3b012..3398d4336 100644 --- a/business_message_builders_test.go +++ b/business_message_builders_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/types" ) func TestBuildBusinessProductMessageMatchesWebGenerator(t *testing.T) { diff --git a/business_product_mutation.go b/business_product_mutation.go index 513c80a99..1b25b1f96 100644 --- a/business_product_mutation.go +++ b/business_product_mutation.go @@ -16,9 +16,9 @@ import ( "sync/atomic" "time" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/socket" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/socket" + "github.com/polymorfa/hypermeow/types" ) const ( diff --git a/business_product_mutation_test.go b/business_product_mutation_test.go index 3856bcc0d..572133405 100644 --- a/business_product_mutation_test.go +++ b/business_product_mutation_test.go @@ -17,8 +17,8 @@ import ( "testing" "time" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) func syntheticProductInput() types.BusinessProductInput { diff --git a/business_profile.go b/business_profile.go index d282837ea..2f2fdd593 100644 --- a/business_profile.go +++ b/business_profile.go @@ -14,9 +14,9 @@ import ( "strings" "time" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/socket" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/socket" + "github.com/polymorfa/hypermeow/types" ) const maxBusinessCoverPhotoBytes = 5 * 1024 * 1024 diff --git a/business_profile_test.go b/business_profile_test.go index 8d8cb11a2..0c0dcf508 100644 --- a/business_profile_test.go +++ b/business_profile_test.go @@ -14,8 +14,8 @@ import ( "testing" "time" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) func profileString(value string) *string { diff --git a/call.go b/call.go index 47b0e2191..485490da4 100644 --- a/call.go +++ b/call.go @@ -9,9 +9,9 @@ package whatsmeow import ( "context" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) func (cli *Client) handleCallEvent(ctx context.Context, node *waBinary.Node) { diff --git a/client.go b/client.go index 761363d64..f809407df 100644 --- a/client.go +++ b/client.go @@ -28,17 +28,17 @@ import ( "golang.org/x/net/proxy" "golang.org/x/sync/semaphore" - "go.mau.fi/whatsmeow/appstate" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/proto/waWa6" - "go.mau.fi/whatsmeow/proto/waWeb" - "go.mau.fi/whatsmeow/socket" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - "go.mau.fi/whatsmeow/util/keys" - waLog "go.mau.fi/whatsmeow/util/log" + "github.com/polymorfa/hypermeow/appstate" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waWa6" + "github.com/polymorfa/hypermeow/proto/waWeb" + "github.com/polymorfa/hypermeow/socket" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + "github.com/polymorfa/hypermeow/util/keys" + waLog "github.com/polymorfa/hypermeow/util/log" ) // EventHandler is a function that can handle events from WhatsApp. diff --git a/client_memory_test.go b/client_memory_test.go index 6d35d79e9..9261c460b 100644 --- a/client_memory_test.go +++ b/client_memory_test.go @@ -6,9 +6,9 @@ import ( "runtime" "testing" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/store" - waLog "go.mau.fi/whatsmeow/util/log" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/store" + waLog "github.com/polymorfa/hypermeow/util/log" ) func TestNewClientDefersSparseState(t *testing.T) { diff --git a/client_test.go b/client_test.go index 895649a7a..c663989c0 100644 --- a/client_test.go +++ b/client_test.go @@ -13,10 +13,10 @@ import ( "os/signal" "syscall" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/store/sqlstore" - "go.mau.fi/whatsmeow/types/events" - waLog "go.mau.fi/whatsmeow/util/log" + whatsmeow "github.com/polymorfa/hypermeow" + "github.com/polymorfa/hypermeow/store/sqlstore" + "github.com/polymorfa/hypermeow/types/events" + waLog "github.com/polymorfa/hypermeow/util/log" ) func eventHandler(evt any) { diff --git a/connectionevents.go b/connectionevents.go index 47ab0fb2f..3e52853d3 100644 --- a/connectionevents.go +++ b/connectionevents.go @@ -10,10 +10,10 @@ import ( "context" "time" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) func (cli *Client) handleStreamError(ctx context.Context, node *waBinary.Node) { diff --git a/cstoken.go b/cstoken.go index b69e849b7..aebb172b7 100644 --- a/cstoken.go +++ b/cstoken.go @@ -11,7 +11,7 @@ import ( "crypto/hmac" "crypto/sha256" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) func shouldSendCsToken(jid types.JID) bool { diff --git a/download-to-file.go b/download-to-file.go index 7518695a2..acbeb22e4 100644 --- a/download-to-file.go +++ b/download-to-file.go @@ -21,8 +21,8 @@ import ( "go.mau.fi/util/fallocate" "go.mau.fi/util/retryafter" - "go.mau.fi/whatsmeow/proto/waMediaTransport" - "go.mau.fi/whatsmeow/util/cbcutil" + "github.com/polymorfa/hypermeow/proto/waMediaTransport" + "github.com/polymorfa/hypermeow/util/cbcutil" ) type File interface { diff --git a/download.go b/download.go index 9ae485864..7058ccb09 100644 --- a/download.go +++ b/download.go @@ -24,14 +24,14 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/proto/waHistorySync" - "go.mau.fi/whatsmeow/proto/waMediaTransport" - "go.mau.fi/whatsmeow/proto/waServerSync" - "go.mau.fi/whatsmeow/socket" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/util/cbcutil" - "go.mau.fi/whatsmeow/util/hkdfutil" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waHistorySync" + "github.com/polymorfa/hypermeow/proto/waMediaTransport" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/socket" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/util/cbcutil" + "github.com/polymorfa/hypermeow/util/hkdfutil" ) // MediaType represents a type of uploaded file on WhatsApp. diff --git a/errors.go b/errors.go index ee78e9065..fbee5025b 100644 --- a/errors.go +++ b/errors.go @@ -13,7 +13,7 @@ import ( "reflect" "strconv" - waBinary "go.mau.fi/whatsmeow/binary" + waBinary "github.com/polymorfa/hypermeow/binary" ) // Miscellaneous errors diff --git a/errors_iq_test.go b/errors_iq_test.go index 48e59e1f5..4cbc77d07 100644 --- a/errors_iq_test.go +++ b/errors_iq_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - waBinary "go.mau.fi/whatsmeow/binary" + waBinary "github.com/polymorfa/hypermeow/binary" ) func TestIQErrorIsDistinguishesSensitiveAttributes(t *testing.T) { diff --git a/go.mod b/go.mod index 2661c2d4f..6478839ec 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module go.mau.fi/whatsmeow +module github.com/polymorfa/hypermeow go 1.25.0 diff --git a/group.go b/group.go index c9b6b861a..b0ee505b9 100644 --- a/group.go +++ b/group.go @@ -13,10 +13,10 @@ import ( "fmt" "strings" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) const InviteLinkPrefix = "https://chat.whatsapp.com/" diff --git a/group_username_test.go b/group_username_test.go index c68c4348a..d83be9b18 100644 --- a/group_username_test.go +++ b/group_username_test.go @@ -3,8 +3,8 @@ package whatsmeow import ( "testing" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) func TestParseGroupParticipantPreservesUsername(t *testing.T) { diff --git a/handshake.go b/handshake.go index 8ce358231..67bc7608b 100644 --- a/handshake.go +++ b/handshake.go @@ -15,10 +15,10 @@ import ( "github.com/polymorfa/libsignal-protocol-go/ecc" "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/proto/waCert" - "go.mau.fi/whatsmeow/proto/waWa6" - "go.mau.fi/whatsmeow/socket" - "go.mau.fi/whatsmeow/util/keys" + "github.com/polymorfa/hypermeow/proto/waCert" + "github.com/polymorfa/hypermeow/proto/waWa6" + "github.com/polymorfa/hypermeow/socket" + "github.com/polymorfa/hypermeow/util/keys" ) const NoiseHandshakeResponseTimeout = 20 * time.Second diff --git a/historysync_test.go b/historysync_test.go index d59f18a57..33933f297 100644 --- a/historysync_test.go +++ b/historysync_test.go @@ -10,10 +10,10 @@ import ( "google.golang.org/protobuf/proto" - waE2E "go.mau.fi/whatsmeow/proto/waE2E" - waHistorySync "go.mau.fi/whatsmeow/proto/waHistorySync" - "go.mau.fi/whatsmeow/store" - waLog "go.mau.fi/whatsmeow/util/log" + waE2E "github.com/polymorfa/hypermeow/proto/waE2E" + waHistorySync "github.com/polymorfa/hypermeow/proto/waHistorySync" + "github.com/polymorfa/hypermeow/store" + waLog "github.com/polymorfa/hypermeow/util/log" ) type historySyncDeviceContainer struct { diff --git a/internals.go b/internals.go index 0010f7efa..6abd42388 100644 --- a/internals.go +++ b/internals.go @@ -1,7 +1,7 @@ // GENERATED BY internals_generate.go; DO NOT EDIT //go:generate go run internals_generate.go -//go:generate goimports -local go.mau.fi/whatsmeow -w internals.go +//go:generate goimports -local github.com/polymorfa/hypermeow -w internals.go package whatsmeow @@ -14,19 +14,19 @@ import ( "github.com/polymorfa/libsignal-protocol-go/keys/prekey" - "go.mau.fi/whatsmeow/appstate" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/proto/waHistorySync" - "go.mau.fi/whatsmeow/proto/waMsgApplication" - "go.mau.fi/whatsmeow/proto/waMsgTransport" - "go.mau.fi/whatsmeow/proto/waServerSync" - "go.mau.fi/whatsmeow/socket" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - "go.mau.fi/whatsmeow/util/keys" + "github.com/polymorfa/hypermeow/appstate" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waCommon" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waHistorySync" + "github.com/polymorfa/hypermeow/proto/waMsgApplication" + "github.com/polymorfa/hypermeow/proto/waMsgTransport" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/socket" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + "github.com/polymorfa/hypermeow/util/keys" ) type DangerousInternalClient struct { diff --git a/internals_generate.go b/internals_generate.go index 9c4d6ee69..9d88b206c 100644 --- a/internals_generate.go +++ b/internals_generate.go @@ -22,7 +22,7 @@ import ( const header = `// GENERATED BY internals_generate.go; DO NOT EDIT //go:generate go run internals_generate.go -//go:generate goimports -local go.mau.fi/whatsmeow -w internals.go +//go:generate goimports -local github.com/polymorfa/hypermeow -w internals.go package whatsmeow diff --git a/keepalive.go b/keepalive.go index 8c50e7b36..b2a8e726c 100644 --- a/keepalive.go +++ b/keepalive.go @@ -11,8 +11,8 @@ import ( "math/rand/v2" "time" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) var ( diff --git a/lid_resolution_test.go b/lid_resolution_test.go index acb0559af..59b7d2508 100644 --- a/lid_resolution_test.go +++ b/lid_resolution_test.go @@ -4,9 +4,9 @@ import ( "context" "testing" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - waLog "go.mau.fi/whatsmeow/util/log" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + waLog "github.com/polymorfa/hypermeow/util/log" ) type cachedLIDStore struct { diff --git a/mediaconn.go b/mediaconn.go index 1451fbc44..4c9d04d43 100644 --- a/mediaconn.go +++ b/mediaconn.go @@ -11,8 +11,8 @@ import ( "fmt" "time" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" ) //type MediaConnIP struct { diff --git a/mediaretry.go b/mediaretry.go index 2c6c28ec8..a0030965c 100644 --- a/mediaretry.go +++ b/mediaretry.go @@ -13,12 +13,12 @@ import ( "go.mau.fi/util/random" "google.golang.org/protobuf/proto" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waMmsRetry" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - "go.mau.fi/whatsmeow/util/gcmutil" - "go.mau.fi/whatsmeow/util/hkdfutil" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waMmsRetry" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + "github.com/polymorfa/hypermeow/util/gcmutil" + "github.com/polymorfa/hypermeow/util/hkdfutil" ) func getMediaRetryKey(mediaKey []byte) (cipherKey []byte) { diff --git a/message.go b/message.go index 343e41fcc..19e3ba5f5 100644 --- a/message.go +++ b/message.go @@ -27,15 +27,15 @@ import ( "go.mau.fi/util/random" "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/appstate" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/proto/waHistorySync" - "go.mau.fi/whatsmeow/proto/waLidMigrationSyncPayload" - "go.mau.fi/whatsmeow/proto/waWeb" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + "github.com/polymorfa/hypermeow/appstate" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waHistorySync" + "github.com/polymorfa/hypermeow/proto/waLidMigrationSyncPayload" + "github.com/polymorfa/hypermeow/proto/waWeb" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) var pbSerializer = store.SignalProtobufSerializer diff --git a/message_name_updates_test.go b/message_name_updates_test.go index 0511a7aa4..bb66faf3e 100644 --- a/message_name_updates_test.go +++ b/message_name_updates_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" ) type blockingMessageNameStore struct { diff --git a/msgsecret.go b/msgsecret.go index 394e8387c..16700eece 100644 --- a/msgsecret.go +++ b/msgsecret.go @@ -17,12 +17,12 @@ import ( "go.mau.fi/util/random" "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - "go.mau.fi/whatsmeow/util/gcmutil" - "go.mau.fi/whatsmeow/util/hkdfutil" + "github.com/polymorfa/hypermeow/proto/waCommon" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + "github.com/polymorfa/hypermeow/util/gcmutil" + "github.com/polymorfa/hypermeow/util/hkdfutil" ) type MsgSecretType string diff --git a/newsletter.go b/newsletter.go index 17122b4c0..af29ac0b3 100644 --- a/newsletter.go +++ b/newsletter.go @@ -12,10 +12,10 @@ import ( "fmt" "time" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waWa6" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waWa6" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" ) // NewsletterSubscribeLiveUpdates subscribes to receive live updates from a WhatsApp channel temporarily (for the duration returned). diff --git a/newsletter_delete.go b/newsletter_delete.go index b16c14b3b..bfcca4342 100644 --- a/newsletter_delete.go +++ b/newsletter_delete.go @@ -5,8 +5,8 @@ import ( "encoding/json" "fmt" - "go.mau.fi/whatsmeow/mex" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/mex" + "github.com/polymorfa/hypermeow/types" ) type deleteNewsletterVariables struct { diff --git a/newsletter_delete_test.go b/newsletter_delete_test.go index e56795fed..fa78af925 100644 --- a/newsletter_delete_test.go +++ b/newsletter_delete_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "go.mau.fi/whatsmeow/proto/waWa6" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/proto/waWa6" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" ) func TestBuildDeleteNewsletterVariablesRejectsNonNewsletterJID(t *testing.T) { diff --git a/notification.go b/notification.go index b61ee3931..7bfe51954 100644 --- a/notification.go +++ b/notification.go @@ -15,12 +15,12 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/appstate" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + "github.com/polymorfa/hypermeow/appstate" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) func (cli *Client) handleEncryptNotification(ctx context.Context, node *waBinary.Node) { diff --git a/notification_device_test.go b/notification_device_test.go index ee9b253da..d8bf1ac00 100644 --- a/notification_device_test.go +++ b/notification_device_test.go @@ -4,10 +4,10 @@ import ( "context" "testing" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - waLog "go.mau.fi/whatsmeow/util/log" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + waLog "github.com/polymorfa/hypermeow/util/log" ) func TestDeviceNotificationUpdatesLIDOnlyCache(t *testing.T) { diff --git a/notification_identity_test.go b/notification_identity_test.go index 04535a8ec..8046f66da 100644 --- a/notification_identity_test.go +++ b/notification_identity_test.go @@ -5,10 +5,10 @@ import ( "slices" "testing" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - waLog "go.mau.fi/whatsmeow/util/log" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + waLog "github.com/polymorfa/hypermeow/util/log" ) type identityChangeStore struct { diff --git a/pair-code.go b/pair-code.go index 6cda22e9b..b7f2c0db8 100644 --- a/pair-code.go +++ b/pair-code.go @@ -21,10 +21,10 @@ import ( "golang.org/x/crypto/curve25519" "golang.org/x/crypto/pbkdf2" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/util/hkdfutil" - "go.mau.fi/whatsmeow/util/keys" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/util/hkdfutil" + "github.com/polymorfa/hypermeow/util/keys" ) // PairClientType is the type of client to use with PairCode. diff --git a/pair-passkey.go b/pair-passkey.go index 4ec037185..c9e25f76a 100644 --- a/pair-passkey.go +++ b/pair-passkey.go @@ -18,14 +18,14 @@ import ( "golang.org/x/crypto/curve25519" "google.golang.org/protobuf/proto" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waCompanionReg" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - "go.mau.fi/whatsmeow/util/gcmutil" - "go.mau.fi/whatsmeow/util/hkdfutil" - "go.mau.fi/whatsmeow/util/keys" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waCompanionReg" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + "github.com/polymorfa/hypermeow/util/gcmutil" + "github.com/polymorfa/hypermeow/util/hkdfutil" + "github.com/polymorfa/hypermeow/util/keys" ) type passkeyLinkingCache struct { diff --git a/pair.go b/pair.go index 6b9c8dc57..0968ea599 100644 --- a/pair.go +++ b/pair.go @@ -17,14 +17,14 @@ import ( "github.com/polymorfa/libsignal-protocol-go/ecc" "google.golang.org/protobuf/proto" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/proto/waAdv" - "go.mau.fi/whatsmeow/proto/waCompanionReg" - "go.mau.fi/whatsmeow/proto/waWa6" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - "go.mau.fi/whatsmeow/util/keys" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waAdv" + "github.com/polymorfa/hypermeow/proto/waCompanionReg" + "github.com/polymorfa/hypermeow/proto/waWa6" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + "github.com/polymorfa/hypermeow/util/keys" ) var ( diff --git a/phone_number_message.go b/phone_number_message.go index ec4c93102..1db9add66 100644 --- a/phone_number_message.go +++ b/phone_number_message.go @@ -1,6 +1,6 @@ package whatsmeow -import "go.mau.fi/whatsmeow/proto/waE2E" +import "github.com/polymorfa/hypermeow/proto/waE2E" func BuildRequestPhoneNumberMessage(contextInfo *waE2E.ContextInfo) *waE2E.Message { return &waE2E.Message{ diff --git a/phone_number_message_test.go b/phone_number_message_test.go index d7e1154f6..bf75ce73f 100644 --- a/phone_number_message_test.go +++ b/phone_number_message_test.go @@ -3,7 +3,7 @@ package whatsmeow import ( "testing" - "go.mau.fi/whatsmeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waE2E" ) func TestBuildRequestPhoneNumberMessage(t *testing.T) { diff --git a/prekeys.go b/prekeys.go index 563a82250..cc2dadd57 100644 --- a/prekeys.go +++ b/prekeys.go @@ -17,9 +17,9 @@ import ( "github.com/polymorfa/libsignal-protocol-go/keys/prekey" "github.com/polymorfa/libsignal-protocol-go/util/optional" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/util/keys" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/util/keys" ) const ( diff --git a/presence.go b/presence.go index e84f51a50..eb378620a 100644 --- a/presence.go +++ b/presence.go @@ -10,9 +10,9 @@ import ( "context" "fmt" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) func (cli *Client) handleChatState(ctx context.Context, node *waBinary.Node) { diff --git a/privacysettings.go b/privacysettings.go index bfe3de802..db4b226e7 100644 --- a/privacysettings.go +++ b/privacysettings.go @@ -11,9 +11,9 @@ import ( "strconv" "time" - waBinary "go.mau.fi/whatsmeow/binary" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" ) // TryFetchPrivacySettings will fetch the user's privacy settings, either from the in-memory cache or from the server. diff --git a/privacysettings_test.go b/privacysettings_test.go index ac0398a22..61fe68961 100644 --- a/privacysettings_test.go +++ b/privacysettings_test.go @@ -3,7 +3,7 @@ package whatsmeow import ( "testing" - "go.mau.fi/whatsmeow/types" + "github.com/polymorfa/hypermeow/types" ) func TestApplyPrivacySettingUpdatesEveryCategory(t *testing.T) { diff --git a/proto/armadilloutil/decode.go b/proto/armadilloutil/decode.go index 31389d33c..0562b1c90 100644 --- a/proto/armadilloutil/decode.go +++ b/proto/armadilloutil/decode.go @@ -6,7 +6,7 @@ import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/proto/waCommon" + "github.com/polymorfa/hypermeow/proto/waCommon" ) var ErrUnsupportedVersion = errors.New("unsupported subprotocol version") diff --git a/proto/extra.go b/proto/extra.go index 81c706f9e..ebd98b376 100644 --- a/proto/extra.go +++ b/proto/extra.go @@ -3,13 +3,13 @@ package armadillo import ( "google.golang.org/protobuf/proto" - "go.mau.fi/whatsmeow/proto/instamadilloAddMessage" - "go.mau.fi/whatsmeow/proto/instamadilloDeleteMessage" - "go.mau.fi/whatsmeow/proto/instamadilloSupplementMessage" - "go.mau.fi/whatsmeow/proto/waArmadilloApplication" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waConsumerApplication" - "go.mau.fi/whatsmeow/proto/waMultiDevice" + "github.com/polymorfa/hypermeow/proto/instamadilloAddMessage" + "github.com/polymorfa/hypermeow/proto/instamadilloDeleteMessage" + "github.com/polymorfa/hypermeow/proto/instamadilloSupplementMessage" + "github.com/polymorfa/hypermeow/proto/waArmadilloApplication" + "github.com/polymorfa/hypermeow/proto/waCommon" + "github.com/polymorfa/hypermeow/proto/waConsumerApplication" + "github.com/polymorfa/hypermeow/proto/waMultiDevice" ) type MessageApplicationSub interface { diff --git a/proto/instamadilloAddMessage/InstamadilloAddMessage.pb.go b/proto/instamadilloAddMessage/InstamadilloAddMessage.pb.go index 43c403624..a89bed661 100644 --- a/proto/instamadilloAddMessage/InstamadilloAddMessage.pb.go +++ b/proto/instamadilloAddMessage/InstamadilloAddMessage.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloAddMessage/InstamadilloAddMessage.proto package instamadilloAddMessage @@ -14,13 +14,13 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - instamadilloCoreTypeActionLog "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeActionLog" - instamadilloCoreTypeAdminMessage "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeAdminMessage" - instamadilloCoreTypeCollection "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeCollection" - instamadilloCoreTypeLink "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeLink" - instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia" - instamadilloCoreTypeText "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeText" - instamadilloXmaContentRef "go.mau.fi/whatsmeow/proto/instamadilloXmaContentRef" + instamadilloCoreTypeActionLog "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeActionLog" + instamadilloCoreTypeAdminMessage "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeAdminMessage" + instamadilloCoreTypeCollection "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeCollection" + instamadilloCoreTypeLink "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeLink" + instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia" + instamadilloCoreTypeText "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeText" + instamadilloXmaContentRef "github.com/polymorfa/hypermeow/proto/instamadilloXmaContentRef" ) const ( @@ -882,7 +882,7 @@ const file_instamadilloAddMessage_InstamadilloAddMessage_proto_rawDesc = "" + "#PLACEHOLDER_TYPE_DECRYPTION_FAILURE\x10\x01\x12.\n" + "*PLACEHOLDER_TYPE_NOT_SUPPORTED_NEED_UPDATE\x10\x02\x12'\n" + "#PLACEHOLDER_TYPE_DEVICE_UNAVAILABLE\x10\x03\x122\n" + - ".PLACEHOLDER_TYPE_NOT_SUPPORTED_NOT_RECOVERABLE\x10\x04B2Z0go.mau.fi/whatsmeow/proto/instamadilloAddMessage" + ".PLACEHOLDER_TYPE_NOT_SUPPORTED_NOT_RECOVERABLE\x10\x04B=Z;github.com/polymorfa/hypermeow/proto/instamadilloAddMessage" var ( file_instamadilloAddMessage_InstamadilloAddMessage_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloAddMessage/InstamadilloAddMessage.proto b/proto/instamadilloAddMessage/InstamadilloAddMessage.proto index 399256797..570d6ef6a 100644 --- a/proto/instamadilloAddMessage/InstamadilloAddMessage.proto +++ b/proto/instamadilloAddMessage/InstamadilloAddMessage.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloAddMessage; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloAddMessage"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloAddMessage"; import "instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto"; import "instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto"; diff --git a/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.pb.go b/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.pb.go index a20af395c..51c1614fb 100644 --- a/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.pb.go +++ b/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto package instamadilloCoreTypeActionLog @@ -141,7 +141,7 @@ const file_instamadilloCoreTypeActionLog_InstamadilloCoreTypeActionLog_proto_raw "\x11actionLogReaction\x18\x01 \x01(\v20.InstamadilloCoreTypeActionLog.ActionLogReactionH\x00R\x11actionLogReactionB\x12\n" + "\x10actionLogSubtype\"7\n" + "\x11ActionLogReaction\x12\"\n" + - "\femojiUnicode\x18\x01 \x01(\tR\femojiUnicodeB9Z7go.mau.fi/whatsmeow/proto/instamadilloCoreTypeActionLog" + "\femojiUnicode\x18\x01 \x01(\tR\femojiUnicodeBDZBgithub.com/polymorfa/hypermeow/proto/instamadilloCoreTypeActionLog" var ( file_instamadilloCoreTypeActionLog_InstamadilloCoreTypeActionLog_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto b/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto index dad19791d..63725d23d 100644 --- a/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto +++ b/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloCoreTypeActionLog; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeActionLog"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeActionLog"; message ActionLog { oneof actionLogSubtype { diff --git a/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.pb.go b/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.pb.go index 0a0195fbb..538dce889 100644 --- a/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.pb.go +++ b/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto package instamadilloCoreTypeAdminMessage @@ -219,7 +219,7 @@ const file_instamadilloCoreTypeAdminMessage_InstamadilloCoreTypeAdminMessage_pro "\x1eDEVICE_ADMIN_MESSAGE_TYPE_NONE\x10\x00\x12J\n" + "FDEVICE_ADMIN_MESSAGE_TYPE_LOCAL_USER_CHANGED_IDENTITY_KEY_NAMED_DEVICE\x10\x01\x12C\n" + "?DEVICE_ADMIN_MESSAGE_TYPE_SECURITY_ALERT_PARTICIPANT_KEY_CHANGE\x10\x02\x12B\n" + - ">DEVICE_ADMIN_MESSAGE_TYPE_SECURITY_ALERT_PARTICIPANT_NEW_LOGIN\x10\x03BDEVICE_ADMIN_MESSAGE_TYPE_SECURITY_ALERT_PARTICIPANT_NEW_LOGIN\x10\x03BGZEgithub.com/polymorfa/hypermeow/proto/instamadilloCoreTypeAdminMessage" var ( file_instamadilloCoreTypeAdminMessage_InstamadilloCoreTypeAdminMessage_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto b/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto index c6bb7e4e4..bcfac7bfb 100644 --- a/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto +++ b/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloCoreTypeAdminMessage; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeAdminMessage"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeAdminMessage"; message AdminMessage { oneof adminMessageSubtype { diff --git a/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.pb.go b/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.pb.go index 91c025eb0..228bf822e 100644 --- a/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.pb.go +++ b/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto package instamadilloCoreTypeCollection @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia" + instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia" ) const ( @@ -84,7 +84,7 @@ const file_instamadilloCoreTypeCollection_InstamadilloCoreTypeCollection_proto_r "\n" + "Collection\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x126\n" + - "\x05media\x18\x02 \x03(\v2 .InstamadilloCoreTypeMedia.MediaR\x05mediaB:Z8go.mau.fi/whatsmeow/proto/instamadilloCoreTypeCollection" + "\x05media\x18\x02 \x03(\v2 .InstamadilloCoreTypeMedia.MediaR\x05mediaBEZCgithub.com/polymorfa/hypermeow/proto/instamadilloCoreTypeCollection" var ( file_instamadilloCoreTypeCollection_InstamadilloCoreTypeCollection_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto b/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto index 4ad9f921a..760e584d9 100644 --- a/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto +++ b/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloCoreTypeCollection; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeCollection"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeCollection"; import "instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto"; diff --git a/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.pb.go b/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.pb.go index f888c6eb8..e6953effe 100644 --- a/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.pb.go +++ b/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto package instamadilloCoreTypeLink @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia" + instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia" ) const ( @@ -256,7 +256,7 @@ const file_instamadilloCoreTypeLink_InstamadilloCoreTypeLink_proto_rawDesc = "" "\bImageUrl\x12\x10\n" + "\x03URL\x18\x01 \x01(\tR\x03URL\x12\x14\n" + "\x05width\x18\x02 \x01(\x05R\x05width\x12\x16\n" + - "\x06height\x18\x03 \x01(\x05R\x06heightB4Z2go.mau.fi/whatsmeow/proto/instamadilloCoreTypeLink" + "\x06height\x18\x03 \x01(\x05R\x06heightB?Z=github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeLink" var ( file_instamadilloCoreTypeLink_InstamadilloCoreTypeLink_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto b/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto index e7b66c6a6..b95ad7e76 100644 --- a/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto +++ b/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloCoreTypeLink; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeLink"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeLink"; import "instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto"; diff --git a/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.pb.go b/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.pb.go index 9dc5b4fa1..1c090bcad 100644 --- a/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.pb.go +++ b/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto package instamadilloCoreTypeMedia @@ -1201,7 +1201,7 @@ const file_instamadilloCoreTypeMedia_InstamadilloCoreTypeMedia_proto_rawDesc = " "$PJPEG_SCAN_CONFIGURATION_UNSPECIFIED\x10\x00\x12\x1f\n" + "\x1bPJPEG_SCAN_CONFIGURATION_WA\x10\x01\x12 \n" + "\x1cPJPEG_SCAN_CONFIGURATION_E15\x10\x02\x12 \n" + - "\x1cPJPEG_SCAN_CONFIGURATION_E35\x10\x03B5Z3go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia" + "\x1cPJPEG_SCAN_CONFIGURATION_E35\x10\x03B@Z>github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia" var ( file_instamadilloCoreTypeMedia_InstamadilloCoreTypeMedia_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto b/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto index eae154790..ce7ca59c3 100644 --- a/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto +++ b/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloCoreTypeMedia; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia"; enum PjpegScanConfiguration { PJPEG_SCAN_CONFIGURATION_UNSPECIFIED = 0; diff --git a/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.pb.go b/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.pb.go index ef0180f81..4ff3bb4a1 100644 --- a/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.pb.go +++ b/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloCoreTypeText/InstamadilloCoreTypeText.proto package instamadilloCoreTypeText @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia" + instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia" ) const ( @@ -450,7 +450,7 @@ const file_instamadilloCoreTypeText_InstamadilloCoreTypeText_proto_rawDesc = "" "\x05style\x18\x03 \x01(\x0e2*.InstamadilloCoreTypeText.Text.FormatStyleR\x05style\"M\n" + "\x1bAnimatedEmojiCharacterRange\x12\x16\n" + "\x06offset\x18\x01 \x01(\x05R\x06offset\x12\x16\n" + - "\x06length\x18\x02 \x01(\x05R\x06lengthB4Z2go.mau.fi/whatsmeow/proto/instamadilloCoreTypeText" + "\x06length\x18\x02 \x01(\x05R\x06lengthB?Z=github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeText" var ( file_instamadilloCoreTypeText_InstamadilloCoreTypeText_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.proto b/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.proto index 8c62e8d3a..503539dc7 100644 --- a/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.proto +++ b/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloCoreTypeText; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeText"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeText"; import "instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto"; diff --git a/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.pb.go b/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.pb.go index 5b732e6f0..a2d2e5a86 100644 --- a/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.pb.go +++ b/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloDeleteMessage/InstamadilloDeleteMessage.proto package instamadilloDeleteMessage @@ -72,7 +72,7 @@ const file_instamadilloDeleteMessage_InstamadilloDeleteMessage_proto_rawDesc = " "\n" + "9instamadilloDeleteMessage/InstamadilloDeleteMessage.proto\x12\x19InstamadilloDeleteMessage\"8\n" + "\x14DeleteMessagePayload\x12 \n" + - "\vmessageOtid\x18\x01 \x01(\tR\vmessageOtidB5Z3go.mau.fi/whatsmeow/proto/instamadilloDeleteMessage" + "\vmessageOtid\x18\x01 \x01(\tR\vmessageOtidB@Z>github.com/polymorfa/hypermeow/proto/instamadilloDeleteMessage" var ( file_instamadilloDeleteMessage_InstamadilloDeleteMessage_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.proto b/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.proto index 0cd7a9ddc..045f3d0f1 100644 --- a/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.proto +++ b/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloDeleteMessage; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloDeleteMessage"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloDeleteMessage"; message DeleteMessagePayload { optional string messageOtid = 1; diff --git a/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.pb.go b/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.pb.go index be1b87a82..36bd4411c 100644 --- a/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.pb.go +++ b/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloSupplementMessage/InstamadilloSupplementMessage.proto package instamadilloSupplementMessage @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia" + instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia" ) const ( @@ -644,7 +644,7 @@ const file_instamadilloSupplementMessage_InstamadilloSupplementMessage_proto_raw "\x18originalTransportPayload\x18\x01 \x01(\fR\x18originalTransportPayload\"\x8d\x01\n" + "\x12MediaInterventions\x12\x18\n" + "\amediaID\x18\x01 \x01(\tR\amediaID\x12]\n" + - "\x10interventionType\x18\x02 \x01(\x0e21.InstamadilloCoreTypeMedia.Media.InterventionTypeR\x10interventionTypeB9Z7go.mau.fi/whatsmeow/proto/instamadilloSupplementMessage" + "\x10interventionType\x18\x02 \x01(\x0e21.InstamadilloCoreTypeMedia.Media.InterventionTypeR\x10interventionTypeBDZBgithub.com/polymorfa/hypermeow/proto/instamadilloSupplementMessage" var ( file_instamadilloSupplementMessage_InstamadilloSupplementMessage_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.proto b/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.proto index 09ff068bc..751040b45 100644 --- a/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.proto +++ b/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloSupplementMessage; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloSupplementMessage"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloSupplementMessage"; import "instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto"; diff --git a/proto/instamadilloTransportPayload/InstamadilloTransportPayload.pb.go b/proto/instamadilloTransportPayload/InstamadilloTransportPayload.pb.go index 9ae3c72cc..aaae3d4dc 100644 --- a/proto/instamadilloTransportPayload/InstamadilloTransportPayload.pb.go +++ b/proto/instamadilloTransportPayload/InstamadilloTransportPayload.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloTransportPayload/InstamadilloTransportPayload.proto package instamadilloTransportPayload @@ -14,9 +14,9 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - instamadilloAddMessage "go.mau.fi/whatsmeow/proto/instamadilloAddMessage" - instamadilloDeleteMessage "go.mau.fi/whatsmeow/proto/instamadilloDeleteMessage" - instamadilloSupplementMessage "go.mau.fi/whatsmeow/proto/instamadilloSupplementMessage" + instamadilloAddMessage "github.com/polymorfa/hypermeow/proto/instamadilloAddMessage" + instamadilloDeleteMessage "github.com/polymorfa/hypermeow/proto/instamadilloDeleteMessage" + instamadilloSupplementMessage "github.com/polymorfa/hypermeow/proto/instamadilloSupplementMessage" ) const ( @@ -297,7 +297,7 @@ const file_instamadilloTransportPayload_InstamadilloTransportPayload_proto_rawDe "\x15PAYLOAD_CREATOR_IGIOS\x10\x01\x12\x18\n" + "\x14PAYLOAD_CREATOR_IG4A\x10\x02\x12\x17\n" + "\x13PAYLOAD_CREATOR_WWW\x10\x03\x12\x1a\n" + - "\x16PAYLOAD_CREATOR_IGLITE\x10\x04B8Z6go.mau.fi/whatsmeow/proto/instamadilloTransportPayload" + "\x16PAYLOAD_CREATOR_IGLITE\x10\x04BCZAgithub.com/polymorfa/hypermeow/proto/instamadilloTransportPayload" var ( file_instamadilloTransportPayload_InstamadilloTransportPayload_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloTransportPayload/InstamadilloTransportPayload.proto b/proto/instamadilloTransportPayload/InstamadilloTransportPayload.proto index 964f84576..24e26689b 100644 --- a/proto/instamadilloTransportPayload/InstamadilloTransportPayload.proto +++ b/proto/instamadilloTransportPayload/InstamadilloTransportPayload.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloTransportPayload; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloTransportPayload"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloTransportPayload"; import "instamadilloAddMessage/InstamadilloAddMessage.proto"; import "instamadilloDeleteMessage/InstamadilloDeleteMessage.proto"; diff --git a/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.pb.go b/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.pb.go index 67e54e5de..e7e4d37a2 100644 --- a/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.pb.go +++ b/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: instamadilloXmaContentRef/InstamadilloXmaContentRef.proto package instamadilloXmaContentRef @@ -1142,7 +1142,7 @@ const file_instamadilloXmaContentRef_InstamadilloXmaContentRef_proto_rawDesc = " "\x1fMediaNoteFetchParamsMessageType\x124\n" + "0MEDIA_NOTE_FETCH_PARAMS_MESSAGE_TYPE_UNSPECIFIED\x10\x00\x120\n" + ",MEDIA_NOTE_FETCH_PARAMS_MESSAGE_TYPE_MENTION\x10\x01\x12.\n" + - "*MEDIA_NOTE_FETCH_PARAMS_MESSAGE_TYPE_REPLY\x10\x02B5Z3go.mau.fi/whatsmeow/proto/instamadilloXmaContentRef" + "*MEDIA_NOTE_FETCH_PARAMS_MESSAGE_TYPE_REPLY\x10\x02B@Z>github.com/polymorfa/hypermeow/proto/instamadilloXmaContentRef" var ( file_instamadilloXmaContentRef_InstamadilloXmaContentRef_proto_rawDescOnce sync.Once diff --git a/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.proto b/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.proto index 00bc9378d..5700144af 100644 --- a/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.proto +++ b/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package InstamadilloXmaContentRef; -option go_package = "go.mau.fi/whatsmeow/proto/instamadilloXmaContentRef"; +option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloXmaContentRef"; enum XmaActionType { XMA_ACTION_TYPE_UNSPECIFIED = 0; diff --git a/proto/waAICommon/WAWebProtobufsAICommon.pb.go b/proto/waAICommon/WAWebProtobufsAICommon.pb.go index 38688f7a5..ca877af44 100644 --- a/proto/waAICommon/WAWebProtobufsAICommon.pb.go +++ b/proto/waAICommon/WAWebProtobufsAICommon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v7.34.1 +// protoc v6.33.6 // source: waAICommon/WAWebProtobufsAICommon.proto package waAICommon @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waCommon "go.mau.fi/whatsmeow/proto/waCommon" + waCommon "github.com/polymorfa/hypermeow/proto/waCommon" ) const ( @@ -8118,7 +8118,7 @@ const file_waAICommon_WAWebProtobufsAICommon_proto_rawDesc = "" + "\tVIDEO_GEN\x10\x03*H\n" + "\x17SessionTransparencyType\x12\x10\n" + "\fUNKNOWN_TYPE\x10\x00\x12\x1b\n" + - "\x17NY_AI_SAFETY_DISCLAIMER\x10\x01B&Z$go.mau.fi/whatsmeow/proto/waAICommon" + "\x17NY_AI_SAFETY_DISCLAIMER\x10\x01B1Z/github.com/polymorfa/hypermeow/proto/waAICommon" var ( file_waAICommon_WAWebProtobufsAICommon_proto_rawDescOnce sync.Once diff --git a/proto/waAICommon/WAWebProtobufsAICommon.proto b/proto/waAICommon/WAWebProtobufsAICommon.proto index 67d619cf0..61f5387f5 100644 --- a/proto/waAICommon/WAWebProtobufsAICommon.proto +++ b/proto/waAICommon/WAWebProtobufsAICommon.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAWebProtobufsAICommon; -option go_package = "go.mau.fi/whatsmeow/proto/waAICommon"; +option go_package = "github.com/polymorfa/hypermeow/proto/waAICommon"; import "waCommon/WACommon.proto"; diff --git a/proto/waAICommonDeprecated/WAAICommonDeprecated.pb.go b/proto/waAICommonDeprecated/WAAICommonDeprecated.pb.go index 2dd8390ad..ab545c7b7 100644 --- a/proto/waAICommonDeprecated/WAAICommonDeprecated.pb.go +++ b/proto/waAICommonDeprecated/WAAICommonDeprecated.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.5 +// protoc v6.33.6 // source: waAICommonDeprecated/WAAICommonDeprecated.proto package waAICommonDeprecated @@ -1599,7 +1599,7 @@ const file_waAICommonDeprecated_WAAICommonDeprecated_proto_rawDesc = "" + "\x18AI_RICH_RESPONSE_DYNAMIC\x10\x06\x12\x18\n" + "\x14AI_RICH_RESPONSE_MAP\x10\a\x12\x1a\n" + "\x16AI_RICH_RESPONSE_LATEX\x10\b\x12\"\n" + - "\x1eAI_RICH_RESPONSE_CONTENT_ITEMS\x10\tB0Z.go.mau.fi/whatsmeow/proto/waAICommonDeprecated" + "\x1eAI_RICH_RESPONSE_CONTENT_ITEMS\x10\tB;Z9github.com/polymorfa/hypermeow/proto/waAICommonDeprecated" var ( file_waAICommonDeprecated_WAAICommonDeprecated_proto_rawDescOnce sync.Once diff --git a/proto/waAICommonDeprecated/WAAICommonDeprecated.proto b/proto/waAICommonDeprecated/WAAICommonDeprecated.proto index 5412ac42e..6f7c4edb5 100644 --- a/proto/waAICommonDeprecated/WAAICommonDeprecated.proto +++ b/proto/waAICommonDeprecated/WAAICommonDeprecated.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAAICommonDeprecated; -option go_package = "go.mau.fi/whatsmeow/proto/waAICommonDeprecated"; +option go_package = "github.com/polymorfa/hypermeow/proto/waAICommonDeprecated"; enum AIRichResponseMessageType { AI_RICH_RESPONSE_TYPE_UNKNOWN = 0; diff --git a/proto/waAdv/WAAdv.pb.go b/proto/waAdv/WAAdv.pb.go index 7e84d0a46..b53247518 100644 --- a/proto/waAdv/WAAdv.pb.go +++ b/proto/waAdv/WAAdv.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waAdv/WAAdv.proto package waAdv @@ -453,7 +453,7 @@ const file_waAdv_WAAdv_proto_rawDesc = "" + "\x11ADVEncryptionType\x12\b\n" + "\x04E2EE\x10\x00\x12\n" + "\n" + - "\x06HOSTED\x10\x01B!Z\x1fgo.mau.fi/whatsmeow/proto/waAdv" + "\x06HOSTED\x10\x01B,Z*github.com/polymorfa/hypermeow/proto/waAdv" var ( file_waAdv_WAAdv_proto_rawDescOnce sync.Once diff --git a/proto/waAdv/WAAdv.proto b/proto/waAdv/WAAdv.proto index 07c96c84b..c69b3b223 100644 --- a/proto/waAdv/WAAdv.proto +++ b/proto/waAdv/WAAdv.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAAdv; -option go_package = "go.mau.fi/whatsmeow/proto/waAdv"; +option go_package = "github.com/polymorfa/hypermeow/proto/waAdv"; enum ADVEncryptionType { E2EE = 0; diff --git a/proto/waAea/WAWebProtobufsAea.pb.go b/proto/waAea/WAWebProtobufsAea.pb.go index 97543d516..c01636654 100644 --- a/proto/waAea/WAWebProtobufsAea.pb.go +++ b/proto/waAea/WAWebProtobufsAea.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v7.34.1 +// protoc v6.33.6 // source: waAea/WAWebProtobufsAea.proto package waAea @@ -135,7 +135,7 @@ const file_waAea_WAWebProtobufsAea_proto_rawDesc = "" + "\vAccountType\x12\b\n" + "\x04E2EE\x10\x00\x12\x0f\n" + "\vHYBRID_E2EE\x10\x01\x12\f\n" + - "\bNON_E2EE\x10\x02B!Z\x1fgo.mau.fi/whatsmeow/proto/waAea" + "\bNON_E2EE\x10\x02B,Z*github.com/polymorfa/hypermeow/proto/waAea" var ( file_waAea_WAWebProtobufsAea_proto_rawDescOnce sync.Once diff --git a/proto/waAea/WAWebProtobufsAea.proto b/proto/waAea/WAWebProtobufsAea.proto index 79a0f3749..dd60765a3 100644 --- a/proto/waAea/WAWebProtobufsAea.proto +++ b/proto/waAea/WAWebProtobufsAea.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAWebProtobufsAea; -option go_package = "go.mau.fi/whatsmeow/proto/waAea"; +option go_package = "github.com/polymorfa/hypermeow/proto/waAea"; message NonE2EEAttestation { enum AccountType { diff --git a/proto/waArmadilloApplication/WAArmadilloApplication.pb.go b/proto/waArmadilloApplication/WAArmadilloApplication.pb.go index 88e33c570..c8b44e2e4 100644 --- a/proto/waArmadilloApplication/WAArmadilloApplication.pb.go +++ b/proto/waArmadilloApplication/WAArmadilloApplication.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waArmadilloApplication/WAArmadilloApplication.proto package waArmadilloApplication @@ -14,8 +14,8 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waArmadilloXMA "go.mau.fi/whatsmeow/proto/waArmadilloXMA" - waCommon "go.mau.fi/whatsmeow/proto/waCommon" + waArmadilloXMA "github.com/polymorfa/hypermeow/proto/waArmadilloXMA" + waCommon "github.com/polymorfa/hypermeow/proto/waCommon" ) const ( @@ -3075,7 +3075,7 @@ const file_waArmadilloApplication_WAArmadilloApplication_proto_rawDesc = "" + "\vMEDIUM_LIKE\x10\x02\x12\x0e\n" + "\n" + "LARGE_LIKE\x10\x03B\t\n" + - "\acontentB2Z0go.mau.fi/whatsmeow/proto/waArmadilloApplication" + "\acontentB=Z;github.com/polymorfa/hypermeow/proto/waArmadilloApplication" var ( file_waArmadilloApplication_WAArmadilloApplication_proto_rawDescOnce sync.Once diff --git a/proto/waArmadilloApplication/WAArmadilloApplication.proto b/proto/waArmadilloApplication/WAArmadilloApplication.proto index 02baef992..4d671d40f 100644 --- a/proto/waArmadilloApplication/WAArmadilloApplication.proto +++ b/proto/waArmadilloApplication/WAArmadilloApplication.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAArmadilloApplication; -option go_package = "go.mau.fi/whatsmeow/proto/waArmadilloApplication"; +option go_package = "github.com/polymorfa/hypermeow/proto/waArmadilloApplication"; import "waArmadilloXMA/WAArmadilloXMA.proto"; import "waCommon/WACommon.proto"; diff --git a/proto/waArmadilloApplication/extra.go b/proto/waArmadilloApplication/extra.go index 4337d3809..5c653940e 100644 --- a/proto/waArmadilloApplication/extra.go +++ b/proto/waArmadilloApplication/extra.go @@ -1,9 +1,9 @@ package waArmadilloApplication import ( - "go.mau.fi/whatsmeow/proto/armadilloutil" - "go.mau.fi/whatsmeow/proto/waCommon" - "go.mau.fi/whatsmeow/proto/waMediaTransport" + "github.com/polymorfa/hypermeow/proto/armadilloutil" + "github.com/polymorfa/hypermeow/proto/waCommon" + "github.com/polymorfa/hypermeow/proto/waMediaTransport" ) func (*Armadillo) IsMessageApplicationSub() {} diff --git a/proto/waArmadilloBackupCommon/WAArmadilloBackupCommon.pb.go b/proto/waArmadilloBackupCommon/WAArmadilloBackupCommon.pb.go index 77a6d9042..06a4154be 100644 --- a/proto/waArmadilloBackupCommon/WAArmadilloBackupCommon.pb.go +++ b/proto/waArmadilloBackupCommon/WAArmadilloBackupCommon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.5 +// protoc v6.33.6 // source: waArmadilloBackupCommon/WAArmadilloBackupCommon.proto package waArmadilloBackupCommon @@ -245,7 +245,7 @@ const file_waArmadilloBackupCommon_WAArmadilloBackupCommon_proto_rawDesc = "" + "\x0epayloadVersion\x18\x05 \x01(\x05R\x0epayloadVersion\x120\n" + "\x13futureProofBehavior\x18\x06 \x01(\x05R\x13futureProofBehavior\x12$\n" + "\rthreadTypeTag\x18\a \x01(\x05R\rthreadTypeTag\x12,\n" + - "\x11clientTimestampMS\x18\b \x01(\x03R\x11clientTimestampMSB3Z1go.mau.fi/whatsmeow/proto/waArmadilloBackupCommon" + "\x11clientTimestampMS\x18\b \x01(\x03R\x11clientTimestampMSB>ZZgithub.com/polymorfa/hypermeow/proto/waArmadilloTransportEvent" var ( file_waArmadilloTransportEvent_WAArmadilloTransportEvent_proto_rawDescOnce sync.Once diff --git a/proto/waArmadilloTransportEvent/WAArmadilloTransportEvent.proto b/proto/waArmadilloTransportEvent/WAArmadilloTransportEvent.proto index 192b2bcfc..9111e4d28 100644 --- a/proto/waArmadilloTransportEvent/WAArmadilloTransportEvent.proto +++ b/proto/waArmadilloTransportEvent/WAArmadilloTransportEvent.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAArmadilloTransportEvent; -option go_package = "go.mau.fi/whatsmeow/proto/waArmadilloTransportEvent"; +option go_package = "github.com/polymorfa/hypermeow/proto/waArmadilloTransportEvent"; message TransportEvent { message Event { diff --git a/proto/waArmadilloXMA/WAArmadilloXMA.pb.go b/proto/waArmadilloXMA/WAArmadilloXMA.pb.go index 38e2caa48..59912e844 100644 --- a/proto/waArmadilloXMA/WAArmadilloXMA.pb.go +++ b/proto/waArmadilloXMA/WAArmadilloXMA.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.5 +// protoc v6.33.6 // source: waArmadilloXMA/WAArmadilloXMA.proto package waArmadilloXMA @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waCommon "go.mau.fi/whatsmeow/proto/waCommon" + waCommon "github.com/polymorfa/hypermeow/proto/waCommon" ) const ( @@ -1096,7 +1096,7 @@ const file_waArmadilloXMA_WAArmadilloXMA_proto_rawDesc = "" + "\x16RTC_ONGOING_AUDIO_CALL\x10\xc0\x17\x12\x1b\n" + "\x16RTC_ONGOING_VIDEO_CALL\x10\xc1\x17\x12 \n" + "\x1bMSG_RECEIVER_FETCH_FALLBACK\x10\xd1\x17\x12\x1a\n" + - "\x15DATACLASS_SENDER_COPY\x10\xa0\x1fB*Z(go.mau.fi/whatsmeow/proto/waArmadilloXMA" + "\x15DATACLASS_SENDER_COPY\x10\xa0\x1fB5Z3github.com/polymorfa/hypermeow/proto/waArmadilloXMA" var ( file_waArmadilloXMA_WAArmadilloXMA_proto_rawDescOnce sync.Once diff --git a/proto/waArmadilloXMA/WAArmadilloXMA.proto b/proto/waArmadilloXMA/WAArmadilloXMA.proto index c9a216f3f..9632cfc65 100644 --- a/proto/waArmadilloXMA/WAArmadilloXMA.proto +++ b/proto/waArmadilloXMA/WAArmadilloXMA.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAArmadilloXMA; -option go_package = "go.mau.fi/whatsmeow/proto/waArmadilloXMA"; +option go_package = "github.com/polymorfa/hypermeow/proto/waArmadilloXMA"; import "waCommon/WACommon.proto"; diff --git a/proto/waBotMetadata/WABotMetadata.pb.go b/proto/waBotMetadata/WABotMetadata.pb.go index 311f564e4..f61eb4c34 100644 --- a/proto/waBotMetadata/WABotMetadata.pb.go +++ b/proto/waBotMetadata/WABotMetadata.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waBotMetadata/WABotMetadata.proto package waBotMetadata @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waCommon "go.mau.fi/whatsmeow/proto/waCommon" + waCommon "github.com/polymorfa/hypermeow/proto/waCommon" ) const ( @@ -5057,7 +5057,7 @@ const file_waBotMetadata_WABotMetadata_proto_rawDesc = "" + "USER_INPUT\x10\x03\x12\r\n" + "\tEMU_FLASH\x10\x04\x12\x16\n" + "\x12EMU_FLASH_FOLLOWUP\x10\x05\x12\t\n" + - "\x05VOICE\x10\x06B)Z'go.mau.fi/whatsmeow/proto/waBotMetadata" + "\x05VOICE\x10\x06B4Z2github.com/polymorfa/hypermeow/proto/waBotMetadata" var ( file_waBotMetadata_WABotMetadata_proto_rawDescOnce sync.Once diff --git a/proto/waBotMetadata/WABotMetadata.proto b/proto/waBotMetadata/WABotMetadata.proto index 0111b38a9..fd35e450a 100644 --- a/proto/waBotMetadata/WABotMetadata.proto +++ b/proto/waBotMetadata/WABotMetadata.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WABotMetadata; -option go_package = "go.mau.fi/whatsmeow/proto/waBotMetadata"; +option go_package = "github.com/polymorfa/hypermeow/proto/waBotMetadata"; import "waCommon/WACommon.proto"; diff --git a/proto/waCert/WACert.pb.go b/proto/waCert/WACert.pb.go index e8c1200b1..b06d28d46 100644 --- a/proto/waCert/WACert.pb.go +++ b/proto/waCert/WACert.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waCert/WACert.proto package waCert @@ -355,7 +355,7 @@ const file_waCert_WACert_proto_rawDesc = "" + "\fissuerSerial\x18\x02 \x01(\rR\fissuerSerial\x12\x10\n" + "\x03key\x18\x03 \x01(\fR\x03key\x12\x1c\n" + "\tnotBefore\x18\x04 \x01(\x04R\tnotBefore\x12\x1a\n" + - "\bnotAfter\x18\x05 \x01(\x04R\bnotAfterB\"Z go.mau.fi/whatsmeow/proto/waCert" + "\bnotAfter\x18\x05 \x01(\x04R\bnotAfterB-Z+github.com/polymorfa/hypermeow/proto/waCert" var ( file_waCert_WACert_proto_rawDescOnce sync.Once diff --git a/proto/waCert/WACert.proto b/proto/waCert/WACert.proto index 1da8c8265..bfd34994a 100644 --- a/proto/waCert/WACert.proto +++ b/proto/waCert/WACert.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WACert; -option go_package = "go.mau.fi/whatsmeow/proto/waCert"; +option go_package = "github.com/polymorfa/hypermeow/proto/waCert"; message NoiseCertificate { message Details { diff --git a/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.pb.go b/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.pb.go index fae5ba172..f47c8fb7c 100644 --- a/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.pb.go +++ b/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waChatLockSettings/WAWebProtobufsChatLockSettings.proto package waChatLockSettings @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waUserPassword "go.mau.fi/whatsmeow/proto/waUserPassword" + waUserPassword "github.com/polymorfa/hypermeow/proto/waUserPassword" ) const ( @@ -85,7 +85,7 @@ const file_waChatLockSettings_WAWebProtobufsChatLockSettings_proto_rawDesc = "" "\x0fhideLockedChats\x18\x01 \x01(\bR\x0fhideLockedChats\x12H\n" + "\n" + "secretCode\x18\x02 \x01(\v2(.WAWebProtobufsUserPassword.UserPasswordR\n" + - "secretCodeB.Z,go.mau.fi/whatsmeow/proto/waChatLockSettings" + "secretCodeB9Z7github.com/polymorfa/hypermeow/proto/waChatLockSettings" var ( file_waChatLockSettings_WAWebProtobufsChatLockSettings_proto_rawDescOnce sync.Once diff --git a/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.proto b/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.proto index 598be63fc..1516d4b16 100644 --- a/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.proto +++ b/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAWebProtobufsChatLockSettings; -option go_package = "go.mau.fi/whatsmeow/proto/waChatLockSettings"; +option go_package = "github.com/polymorfa/hypermeow/proto/waChatLockSettings"; import "waUserPassword/WAWebProtobufsUserPassword.proto"; diff --git a/proto/waCommon/WACommon.pb.go b/proto/waCommon/WACommon.pb.go index 2861ab14f..38afb79ca 100644 --- a/proto/waCommon/WACommon.pb.go +++ b/proto/waCommon/WACommon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.5 +// protoc v6.33.6 // source: waCommon/WACommon.proto package waCommon @@ -702,7 +702,7 @@ const file_waCommon_WACommon_proto_rawDesc = "" + "\vPLACEHOLDER\x10\x00\x12\x12\n" + "\x0eNO_PLACEHOLDER\x10\x01\x12\n" + "\n" + - "\x06IGNORE\x10\x02B$Z\"go.mau.fi/whatsmeow/proto/waCommon" + "\x06IGNORE\x10\x02B/Z-github.com/polymorfa/hypermeow/proto/waCommon" var ( file_waCommon_WACommon_proto_rawDescOnce sync.Once diff --git a/proto/waCommon/WACommon.proto b/proto/waCommon/WACommon.proto index c75cd1748..b3ae309ca 100644 --- a/proto/waCommon/WACommon.proto +++ b/proto/waCommon/WACommon.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WACommon; -option go_package = "go.mau.fi/whatsmeow/proto/waCommon"; +option go_package = "github.com/polymorfa/hypermeow/proto/waCommon"; enum FutureProofBehavior { PLACEHOLDER = 0; diff --git a/proto/waCommonParameterised/WACommonParameterised.pb.go b/proto/waCommonParameterised/WACommonParameterised.pb.go index 9d4162d7b..4bb84caab 100644 --- a/proto/waCommonParameterised/WACommonParameterised.pb.go +++ b/proto/waCommonParameterised/WACommonParameterised.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waCommonParameterised/WACommonParameterised.proto package waCommonParameterised @@ -562,7 +562,7 @@ const file_waCommonParameterised_WACommonParameterised_proto_rawDesc = "" + "\vPLACEHOLDER\x10\x00\x12\x12\n" + "\x0eNO_PLACEHOLDER\x10\x01\x12\n" + "\n" + - "\x06IGNORE\x10\x02B1Z/go.mau.fi/whatsmeow/proto/waCommonParameterised" + "\x06IGNORE\x10\x02B\n" + - "\bprotocol\x18\x01 \x01(\v2\".WACommonParameterised.SubProtocolR\bprotocolB>Zgithub.com/polymorfa/hypermeow/proto/waLidMigrationSyncPayload" var ( file_waLidMigrationSyncPayload_WAWebProtobufLidMigrationSyncPayload_proto_rawDescOnce sync.Once diff --git a/proto/waLidMigrationSyncPayload/WAWebProtobufLidMigrationSyncPayload.proto b/proto/waLidMigrationSyncPayload/WAWebProtobufLidMigrationSyncPayload.proto index a0b044d14..7a308d5df 100644 --- a/proto/waLidMigrationSyncPayload/WAWebProtobufLidMigrationSyncPayload.proto +++ b/proto/waLidMigrationSyncPayload/WAWebProtobufLidMigrationSyncPayload.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAWebProtobufLidMigrationSyncPayload; -option go_package = "go.mau.fi/whatsmeow/proto/waLidMigrationSyncPayload"; +option go_package = "github.com/polymorfa/hypermeow/proto/waLidMigrationSyncPayload"; message LIDMigrationMapping { required uint64 pn = 1; diff --git a/proto/waMediaEntryData/WAMediaEntryData.pb.go b/proto/waMediaEntryData/WAMediaEntryData.pb.go index ba14f146e..a82b89ab4 100644 --- a/proto/waMediaEntryData/WAMediaEntryData.pb.go +++ b/proto/waMediaEntryData/WAMediaEntryData.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waMediaEntryData/WAMediaEntryData.proto package waMediaEntryData @@ -372,7 +372,7 @@ const file_waMediaEntryData_WAMediaEntryData_proto_rawDesc = "" + "directPath\x12\x1a\n" + "\bmediaKey\x18\x04 \x01(\fR\bmediaKey\x12,\n" + "\x11mediaKeyTimestamp\x18\x05 \x01(\x03R\x11mediaKeyTimestamp\x12\x1a\n" + - "\bobjectID\x18\x06 \x01(\tR\bobjectIDB,Z*go.mau.fi/whatsmeow/proto/waMediaEntryData" + "\bobjectID\x18\x06 \x01(\tR\bobjectIDB7Z5github.com/polymorfa/hypermeow/proto/waMediaEntryData" var ( file_waMediaEntryData_WAMediaEntryData_proto_rawDescOnce sync.Once diff --git a/proto/waMediaEntryData/WAMediaEntryData.proto b/proto/waMediaEntryData/WAMediaEntryData.proto index a9419386a..1afb9fb38 100644 --- a/proto/waMediaEntryData/WAMediaEntryData.proto +++ b/proto/waMediaEntryData/WAMediaEntryData.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAMediaEntryData; -option go_package = "go.mau.fi/whatsmeow/proto/waMediaEntryData"; +option go_package = "github.com/polymorfa/hypermeow/proto/waMediaEntryData"; message MediaEntry { message ProgressiveJpegDetails { diff --git a/proto/waMediaTransport/WAMediaTransport.pb.go b/proto/waMediaTransport/WAMediaTransport.pb.go index d79f20c3d..ff89aecad 100644 --- a/proto/waMediaTransport/WAMediaTransport.pb.go +++ b/proto/waMediaTransport/WAMediaTransport.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waMediaTransport/WAMediaTransport.proto package waMediaTransport @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waCommon "go.mau.fi/whatsmeow/proto/waCommon" + waCommon "github.com/polymorfa/hypermeow/proto/waCommon" ) const ( @@ -2033,7 +2033,7 @@ const file_waMediaTransport_WAMediaTransport_proto_rawDesc = "" + "\bIntegral\x12\x16\n" + "\x05vcard\x18\x01 \x01(\tH\x00R\x05vcard\x12R\n" + "\x11downloadableVcard\x18\x02 \x01(\v2\".WAMediaTransport.WAMediaTransportH\x00R\x11downloadableVcardB\t\n" + - "\acontactB,Z*go.mau.fi/whatsmeow/proto/waMediaTransport" + "\acontactB7Z5github.com/polymorfa/hypermeow/proto/waMediaTransport" var ( file_waMediaTransport_WAMediaTransport_proto_rawDescOnce sync.Once diff --git a/proto/waMediaTransport/WAMediaTransport.proto b/proto/waMediaTransport/WAMediaTransport.proto index 59ef7d955..ef2ada0a3 100644 --- a/proto/waMediaTransport/WAMediaTransport.proto +++ b/proto/waMediaTransport/WAMediaTransport.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAMediaTransport; -option go_package = "go.mau.fi/whatsmeow/proto/waMediaTransport"; +option go_package = "github.com/polymorfa/hypermeow/proto/waMediaTransport"; import "waCommon/WACommon.proto"; diff --git a/proto/waMmsRetry/WAMmsRetry.pb.go b/proto/waMmsRetry/WAMmsRetry.pb.go index 6128a8090..da1b0a28c 100644 --- a/proto/waMmsRetry/WAMmsRetry.pb.go +++ b/proto/waMmsRetry/WAMmsRetry.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waMmsRetry/WAMmsRetry.proto package waMmsRetry @@ -216,7 +216,7 @@ const file_waMmsRetry_WAMmsRetry_proto_rawDesc = "" + "\tNOT_FOUND\x10\x02\x12\x14\n" + "\x10DECRYPTION_ERROR\x10\x03\"0\n" + "\x12ServerErrorReceipt\x12\x1a\n" + - "\bstanzaID\x18\x01 \x01(\tR\bstanzaIDB&Z$go.mau.fi/whatsmeow/proto/waMmsRetry" + "\bstanzaID\x18\x01 \x01(\tR\bstanzaIDB1Z/github.com/polymorfa/hypermeow/proto/waMmsRetry" var ( file_waMmsRetry_WAMmsRetry_proto_rawDescOnce sync.Once diff --git a/proto/waMmsRetry/WAMmsRetry.proto b/proto/waMmsRetry/WAMmsRetry.proto index c6b18610c..f9816e18a 100644 --- a/proto/waMmsRetry/WAMmsRetry.proto +++ b/proto/waMmsRetry/WAMmsRetry.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAMmsRetry; -option go_package = "go.mau.fi/whatsmeow/proto/waMmsRetry"; +option go_package = "github.com/polymorfa/hypermeow/proto/waMmsRetry"; message MediaRetryNotification { enum ResultType { diff --git a/proto/waMsgApplication/WAMsgApplication.pb.go b/proto/waMsgApplication/WAMsgApplication.pb.go index b11c27870..09be071cc 100644 --- a/proto/waMsgApplication/WAMsgApplication.pb.go +++ b/proto/waMsgApplication/WAMsgApplication.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waMsgApplication/WAMsgApplication.proto package waMsgApplication @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waCommon "go.mau.fi/whatsmeow/proto/waCommon" + waCommon "github.com/polymorfa/hypermeow/proto/waCommon" ) const ( @@ -1050,7 +1050,7 @@ const file_waMsgApplication_WAMsgApplication_proto_rawDesc = "" + "\aUNKNOWN\x10\x00\x12\r\n" + "\tSEEN_ONCE\x10\x01\x12\x19\n" + "\x15SEEN_BASED_WITH_TIMER\x10\x02\x12\x19\n" + - "\x15SEND_BASED_WITH_TIMER\x10\x03B,Z*go.mau.fi/whatsmeow/proto/waMsgApplication" + "\x15SEND_BASED_WITH_TIMER\x10\x03B7Z5github.com/polymorfa/hypermeow/proto/waMsgApplication" var ( file_waMsgApplication_WAMsgApplication_proto_rawDescOnce sync.Once diff --git a/proto/waMsgApplication/WAMsgApplication.proto b/proto/waMsgApplication/WAMsgApplication.proto index 282e52734..9048b5b1e 100644 --- a/proto/waMsgApplication/WAMsgApplication.proto +++ b/proto/waMsgApplication/WAMsgApplication.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAMsgApplication; -option go_package = "go.mau.fi/whatsmeow/proto/waMsgApplication"; +option go_package = "github.com/polymorfa/hypermeow/proto/waMsgApplication"; import "waCommon/WACommon.proto"; diff --git a/proto/waMsgApplication/extra.go b/proto/waMsgApplication/extra.go index b98f78100..b49a0faf2 100644 --- a/proto/waMsgApplication/extra.go +++ b/proto/waMsgApplication/extra.go @@ -1,10 +1,10 @@ package waMsgApplication import ( - "go.mau.fi/whatsmeow/proto/armadilloutil" - "go.mau.fi/whatsmeow/proto/waArmadilloApplication" - "go.mau.fi/whatsmeow/proto/waConsumerApplication" - "go.mau.fi/whatsmeow/proto/waMultiDevice" + "github.com/polymorfa/hypermeow/proto/armadilloutil" + "github.com/polymorfa/hypermeow/proto/waArmadilloApplication" + "github.com/polymorfa/hypermeow/proto/waConsumerApplication" + "github.com/polymorfa/hypermeow/proto/waMultiDevice" ) const ( diff --git a/proto/waMsgTransport/WAMsgTransport.pb.go b/proto/waMsgTransport/WAMsgTransport.pb.go index c0b2faf10..255a15536 100644 --- a/proto/waMsgTransport/WAMsgTransport.pb.go +++ b/proto/waMsgTransport/WAMsgTransport.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waMsgTransport/WAMsgTransport.proto package waMsgTransport @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waCommon "go.mau.fi/whatsmeow/proto/waCommon" + waCommon "github.com/polymorfa/hypermeow/proto/waCommon" ) const ( @@ -779,7 +779,7 @@ const file_waMsgTransport_WAMsgTransport_proto_rawDesc = "" + "\rsenderKeyHash\x18\x01 \x01(\fR\rsenderKeyHash\x12(\n" + "\x0fsenderTimestamp\x18\x02 \x01(\x04R\x0fsenderTimestamp\x12*\n" + "\x10recipientKeyHash\x18\b \x01(\fR\x10recipientKeyHash\x12.\n" + - "\x12recipientTimestamp\x18\t \x01(\x04R\x12recipientTimestampB*Z(go.mau.fi/whatsmeow/proto/waMsgTransport" + "\x12recipientTimestamp\x18\t \x01(\x04R\x12recipientTimestampB5Z3github.com/polymorfa/hypermeow/proto/waMsgTransport" var ( file_waMsgTransport_WAMsgTransport_proto_rawDescOnce sync.Once diff --git a/proto/waMsgTransport/WAMsgTransport.proto b/proto/waMsgTransport/WAMsgTransport.proto index fb5bfdadd..e2465e921 100644 --- a/proto/waMsgTransport/WAMsgTransport.proto +++ b/proto/waMsgTransport/WAMsgTransport.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAMsgTransport; -option go_package = "go.mau.fi/whatsmeow/proto/waMsgTransport"; +option go_package = "github.com/polymorfa/hypermeow/proto/waMsgTransport"; import "waCommon/WACommon.proto"; diff --git a/proto/waMsgTransport/extra.go b/proto/waMsgTransport/extra.go index d8758f1e7..412b35c6e 100644 --- a/proto/waMsgTransport/extra.go +++ b/proto/waMsgTransport/extra.go @@ -1,9 +1,9 @@ package waMsgTransport import ( - "go.mau.fi/whatsmeow/proto/armadilloutil" - "go.mau.fi/whatsmeow/proto/instamadilloTransportPayload" - "go.mau.fi/whatsmeow/proto/waMsgApplication" + "github.com/polymorfa/hypermeow/proto/armadilloutil" + "github.com/polymorfa/hypermeow/proto/instamadilloTransportPayload" + "github.com/polymorfa/hypermeow/proto/waMsgApplication" ) const ( diff --git a/proto/waMultiDevice/WAMultiDevice.pb.go b/proto/waMultiDevice/WAMultiDevice.pb.go index 17691d4b4..b93c322a7 100644 --- a/proto/waMultiDevice/WAMultiDevice.pb.go +++ b/proto/waMultiDevice/WAMultiDevice.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waMultiDevice/WAMultiDevice.proto package waMultiDevice @@ -652,7 +652,7 @@ const file_waMultiDevice_WAMultiDevice_proto_rawDesc = "" + "\x11AppStateSyncKeyId\x12\x14\n" + "\x05keyID\x18\x01 \x01(\fR\x05keyIDB\x11\n" + "\x0fapplicationData\x1a\b\n" + - "\x06SignalB)Z'go.mau.fi/whatsmeow/proto/waMultiDevice" + "\x06SignalB4Z2github.com/polymorfa/hypermeow/proto/waMultiDevice" var ( file_waMultiDevice_WAMultiDevice_proto_rawDescOnce sync.Once diff --git a/proto/waMultiDevice/WAMultiDevice.proto b/proto/waMultiDevice/WAMultiDevice.proto index 3ddc23088..d3d55d658 100644 --- a/proto/waMultiDevice/WAMultiDevice.proto +++ b/proto/waMultiDevice/WAMultiDevice.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAMultiDevice; -option go_package = "go.mau.fi/whatsmeow/proto/waMultiDevice"; +option go_package = "github.com/polymorfa/hypermeow/proto/waMultiDevice"; message MultiDevice { message Metadata { diff --git a/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.pb.go b/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.pb.go index 2837eb66e..8eb24507b 100644 --- a/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.pb.go +++ b/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto package waQuickPromotionSurfaces @@ -447,7 +447,7 @@ const file_waQuickPromotionSurfaces_WAWebProtobufsQuickPromotionSurfaces_proto_r "ClauseType\x12\a\n" + "\x03AND\x10\x01\x12\x06\n" + "\x02OR\x10\x02\x12\a\n" + - "\x03NOR\x10\x03B4Z2go.mau.fi/whatsmeow/proto/waQuickPromotionSurfaces" + "\x03NOR\x10\x03B?Z=github.com/polymorfa/hypermeow/proto/waQuickPromotionSurfaces" var ( file_waQuickPromotionSurfaces_WAWebProtobufsQuickPromotionSurfaces_proto_rawDescOnce sync.Once diff --git a/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto b/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto index 4fb264325..cf522cd18 100644 --- a/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto +++ b/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAWebProtobufsQuickPromotionSurfaces; -option go_package = "go.mau.fi/whatsmeow/proto/waQuickPromotionSurfaces"; +option go_package = "github.com/polymorfa/hypermeow/proto/waQuickPromotionSurfaces"; message QP { enum FilterResult { diff --git a/proto/waReporting/WAWebProtobufsReporting.pb.go b/proto/waReporting/WAWebProtobufsReporting.pb.go index ef680f7ba..ddcd4fac8 100644 --- a/proto/waReporting/WAWebProtobufsReporting.pb.go +++ b/proto/waReporting/WAWebProtobufsReporting.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waReporting/WAWebProtobufsReporting.proto package waReporting @@ -252,7 +252,7 @@ const file_waReporting_WAWebProtobufsReporting_proto_rawDesc = "" + "\bsubfield\x18\x05 \x03(\v2,.WAWebProtobufsReporting.Field.SubfieldEntryR\bsubfield\x1a[\n" + "\rSubfieldEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\rR\x03key\x124\n" + - "\x05value\x18\x02 \x01(\v2\x1e.WAWebProtobufsReporting.FieldR\x05value:\x028\x01B'Z%go.mau.fi/whatsmeow/proto/waReportingb\x06proto3" + "\x05value\x18\x02 \x01(\v2\x1e.WAWebProtobufsReporting.FieldR\x05value:\x028\x01B2Z0github.com/polymorfa/hypermeow/proto/waReportingb\x06proto3" var ( file_waReporting_WAWebProtobufsReporting_proto_rawDescOnce sync.Once diff --git a/proto/waReporting/WAWebProtobufsReporting.proto b/proto/waReporting/WAWebProtobufsReporting.proto index 598dadd1d..2f74b969b 100644 --- a/proto/waReporting/WAWebProtobufsReporting.proto +++ b/proto/waReporting/WAWebProtobufsReporting.proto @@ -1,6 +1,6 @@ syntax = "proto3"; package WAWebProtobufsReporting; -option go_package = "go.mau.fi/whatsmeow/proto/waReporting"; +option go_package = "github.com/polymorfa/hypermeow/proto/waReporting"; message Reportable { uint32 minVersion = 1; diff --git a/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.pb.go b/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.pb.go index 7f077987e..7e8e80f93 100644 --- a/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.pb.go +++ b/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waRoutingInfo/WAWebProtobufsRoutingInfo.proto package waRoutingInfo @@ -117,7 +117,7 @@ const file_waRoutingInfo_WAWebProtobufsRoutingInfo_proto_rawDesc = "" + "\x06taskID\x18\x03 \x01(\x05R\x06taskID\x12\x14\n" + "\x05debug\x18\x04 \x01(\bR\x05debug\x12\x16\n" + "\x06tcpBbr\x18\x05 \x01(\bR\x06tcpBbr\x12\"\n" + - "\ftcpKeepalive\x18\x06 \x01(\bR\ftcpKeepaliveB)Z'go.mau.fi/whatsmeow/proto/waRoutingInfo" + "\ftcpKeepalive\x18\x06 \x01(\bR\ftcpKeepaliveB4Z2github.com/polymorfa/hypermeow/proto/waRoutingInfo" var ( file_waRoutingInfo_WAWebProtobufsRoutingInfo_proto_rawDescOnce sync.Once diff --git a/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.proto b/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.proto index 3885d0a72..8ed63255e 100644 --- a/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.proto +++ b/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAWebProtobufsRoutingInfo; -option go_package = "go.mau.fi/whatsmeow/proto/waRoutingInfo"; +option go_package = "github.com/polymorfa/hypermeow/proto/waRoutingInfo"; message RoutingInfo { repeated int32 regionID = 1; diff --git a/proto/waServerSync/WAWebProtobufsServerSync.pb.go b/proto/waServerSync/WAWebProtobufsServerSync.pb.go index 41d74c95e..de9a4bcf3 100644 --- a/proto/waServerSync/WAWebProtobufsServerSync.pb.go +++ b/proto/waServerSync/WAWebProtobufsServerSync.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v7.34.1 +// protoc v6.33.6 // source: waServerSync/WAWebProtobufsServerSync.proto package waServerSync @@ -955,7 +955,7 @@ const file_waServerSync_WAWebProtobufsServerSync_proto_rawDesc = "" + "\x05index\x18\x01 \x01(\v2$.WAWebProtobufsServerSync.SyncdIndexR\x05index\x12:\n" + "\x05value\x18\x02 \x01(\v2$.WAWebProtobufsServerSync.SyncdValueR\x05value\x12\"\n" + "\fdirtyVersion\x18\x03 \x01(\x04R\fdirtyVersion\x12T\n" + - "\toperation\x18\x04 \x01(\x0e26.WAWebProtobufsServerSync.SyncdMutation.SyncdOperationR\toperationB(Z&go.mau.fi/whatsmeow/proto/waServerSync" + "\toperation\x18\x04 \x01(\x0e26.WAWebProtobufsServerSync.SyncdMutation.SyncdOperationR\toperationB3Z1github.com/polymorfa/hypermeow/proto/waServerSync" var ( file_waServerSync_WAWebProtobufsServerSync_proto_rawDescOnce sync.Once diff --git a/proto/waServerSync/WAWebProtobufsServerSync.proto b/proto/waServerSync/WAWebProtobufsServerSync.proto index efddc20a5..195e01792 100644 --- a/proto/waServerSync/WAWebProtobufsServerSync.proto +++ b/proto/waServerSync/WAWebProtobufsServerSync.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAWebProtobufsServerSync; -option go_package = "go.mau.fi/whatsmeow/proto/waServerSync"; +option go_package = "github.com/polymorfa/hypermeow/proto/waServerSync"; message SyncdMutation { enum SyncdOperation { diff --git a/proto/waStatusAttributions/WAStatusAttributions.pb.go b/proto/waStatusAttributions/WAStatusAttributions.pb.go index 2badbbd0c..12f879583 100644 --- a/proto/waStatusAttributions/WAStatusAttributions.pb.go +++ b/proto/waStatusAttributions/WAStatusAttributions.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v6.33.5 +// protoc v6.33.6 // source: waStatusAttributions/WAStatusAttributions.proto package waStatusAttributions @@ -1029,7 +1029,7 @@ const file_waStatusAttributions_WAStatusAttributions_proto_rawDesc = "" + "\x11NEWSLETTER_STATUS\x10\t\x12\x18\n" + "\x14STATUS_CLOSE_SHARING\x10\n" + "B\x11\n" + - "\x0fattributionDataB0Z.go.mau.fi/whatsmeow/proto/waStatusAttributions" + "\x0fattributionDataB;Z9github.com/polymorfa/hypermeow/proto/waStatusAttributions" var ( file_waStatusAttributions_WAStatusAttributions_proto_rawDescOnce sync.Once diff --git a/proto/waStatusAttributions/WAStatusAttributions.proto b/proto/waStatusAttributions/WAStatusAttributions.proto index 8d61e137a..7a05aeed3 100644 --- a/proto/waStatusAttributions/WAStatusAttributions.proto +++ b/proto/waStatusAttributions/WAStatusAttributions.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAStatusAttributions; -option go_package = "go.mau.fi/whatsmeow/proto/waStatusAttributions"; +option go_package = "github.com/polymorfa/hypermeow/proto/waStatusAttributions"; message StatusAttribution { enum Type { diff --git a/proto/waSyncAction/WAWebProtobufSyncAction.pb.go b/proto/waSyncAction/WAWebProtobufSyncAction.pb.go index 05dff72cc..38670926a 100644 --- a/proto/waSyncAction/WAWebProtobufSyncAction.pb.go +++ b/proto/waSyncAction/WAWebProtobufSyncAction.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v7.34.1 +// protoc v6.33.6 // source: waSyncAction/WAWebProtobufSyncAction.proto package waSyncAction @@ -14,9 +14,9 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waChatLockSettings "go.mau.fi/whatsmeow/proto/waChatLockSettings" - waCommon "go.mau.fi/whatsmeow/proto/waCommon" - waDeviceCapabilities "go.mau.fi/whatsmeow/proto/waDeviceCapabilities" + waChatLockSettings "github.com/polymorfa/hypermeow/proto/waChatLockSettings" + waCommon "github.com/polymorfa/hypermeow/proto/waCommon" + waDeviceCapabilities "github.com/polymorfa/hypermeow/proto/waDeviceCapabilities" ) const ( @@ -9223,7 +9223,7 @@ const file_waSyncAction_WAWebProtobufSyncAction_proto_rawDesc = "" + "PROCESSING\x10\x03\x12\n" + "\n" + "\x06FAILED\x10\x04\x12\b\n" + - "\x04SENT\x10\x05B(Z&go.mau.fi/whatsmeow/proto/waSyncAction" + "\x04SENT\x10\x05B3Z1github.com/polymorfa/hypermeow/proto/waSyncAction" var ( file_waSyncAction_WAWebProtobufSyncAction_proto_rawDescOnce sync.Once diff --git a/proto/waSyncAction/WAWebProtobufSyncAction.proto b/proto/waSyncAction/WAWebProtobufSyncAction.proto index bcb650eeb..78f2d34d6 100644 --- a/proto/waSyncAction/WAWebProtobufSyncAction.proto +++ b/proto/waSyncAction/WAWebProtobufSyncAction.proto @@ -1,6 +1,6 @@ syntax = "proto2"; package WAWebProtobufSyncAction; -option go_package = "go.mau.fi/whatsmeow/proto/waSyncAction"; +option go_package = "github.com/polymorfa/hypermeow/proto/waSyncAction"; import "waChatLockSettings/WAWebProtobufsChatLockSettings.proto"; import "waDeviceCapabilities/WAWebProtobufsDeviceCapabilities.proto"; diff --git a/proto/waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.pb.go b/proto/waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.pb.go index 853a48ce0..126f50d81 100644 --- a/proto/waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.pb.go +++ b/proto/waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v6.33.6 // source: waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.proto package waSyncdSnapshotRecovery @@ -14,7 +14,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - waSyncAction "go.mau.fi/whatsmeow/proto/waSyncAction" + waSyncAction "github.com/polymorfa/hypermeow/proto/waSyncAction" ) const ( @@ -211,7 +211,7 @@ const file_waSyncdSnapshotRecovery_WAWebProtobufsSyncdSnapshotRecovery_proto_raw "\x05keyID\x18\x02 \x01(\fR\x05keyID\x12\x10\n" + "\x03mac\x18\x03 \x01(\fR\x03mac\"(\n" + "\fSyncdVersion\x12\x18\n" + - "\aversion\x18\x01 \x01(\x04R\aversionB3Z1go.mau.fi/whatsmeow/proto/waSyncdSnapshotRecovery" + "\aversion\x18\x01 \x01(\x04R\aversionB>Z Date: Mon, 10 Aug 2026 22:24:02 +0300 Subject: [PATCH 155/163] docs: explain the hypermeow module / whatsmeow package split godoc renders the root package as "whatsmeow package - github.com/polymorfa/hypermeow", which reads like the rename did not take. It did: only the module path moved. Say so directly, and give the reason the package clause stays on the upstream name. --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 13f8fae4d..307b32489 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,14 @@ import whatsmeow "github.com/polymorfa/hypermeow" go get github.com/polymorfa/hypermeow ``` -Package names are unchanged from upstream (the root package is still `whatsmeow`), so only import paths differ. Import the root package under its package name, as above, when your tooling expects the path's last element to match. +### The module is `hypermeow`, the package is `whatsmeow` + +Only the *module path* moved. The Go *package* names are unchanged from upstream, so the root package is still declared `package whatsmeow`. Both of these are expected: + +- godoc renders the title as **"whatsmeow package - github.com/polymorfa/hypermeow"**; +- the import path's last element (`hypermeow`) does not match the package name (`whatsmeow`), so import the root package under an explicit alias as shown above. + +This is deliberate. Keeping the upstream package names means no call site changes when migrating - `whatsmeow.Client`, `whatsmeow.NewClient` and friends all still resolve - and merges from tulir's upstream do not conflict on the package clause of every file. Sub-packages are unaffected, since their path element already matches their package name (`.../store` is `package store`, and so on). Migrating from the previous `replace go.mau.fi/whatsmeow => github.com/polymorfa/hypermeow` setup: drop the `replace` line, add a normal `require` on `github.com/polymorfa/hypermeow`, and rewrite `go.mau.fi/whatsmeow` import paths to `github.com/polymorfa/hypermeow`. A `replace` directive is only honoured in the main module, so the previous arrangement did not carry to anything that depended on your module in turn; a direct requirement does. From 3ea2d78aab3d1b8045d484d6510c194b625faacd Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 23:08:17 +0300 Subject: [PATCH 156/163] docs: warn that only one whatsmeow may be linked per binary A distinct module path lets Go compile HyperMeow alongside upstream whatsmeow. Both keep upstream's generated descriptor paths, so the process-global protobuf registry panics before main. The replace directive made that impossible; document the constraint and how to assert it at build time. --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 307b32489..a8761e4a8 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,24 @@ Only the *module path* moved. The Go *package* names are unchanged from upstream This is deliberate. Keeping the upstream package names means no call site changes when migrating - `whatsmeow.Client`, `whatsmeow.NewClient` and friends all still resolve - and merges from tulir's upstream do not conflict on the package clause of every file. Sub-packages are unaffected, since their path element already matches their package name (`.../store` is `package store`, and so on). +### Only one whatsmeow may be linked into a binary + +HyperMeow keeps upstream's generated protobuf descriptor paths (`waCommon/WACommon.proto` and friends) and their symbol namespaces. Those are registered in a process-global registry, so linking HyperMeow **and** upstream `go.mau.fi/whatsmeow` into the same binary panics before `main`: + +``` +proto: file "waCommon/WACommon.proto" is already registered +``` + +The old `replace` arrangement made this impossible, because both import paths resolved to one module. A distinct module path removes that guarantee, so a partially migrated dependency graph — where one of your dependencies still requires `go.mau.fi/whatsmeow` — is not safe. + +The failure is loud and immediate rather than silent, but it surfaces at process start. Check for it at build time instead: + +```sh +go mod why go.mau.fi/whatsmeow # should report the module is not needed +``` + +or assert it in a test via `debug.ReadBuildInfo()`, failing if any dependency reports the path `go.mau.fi/whatsmeow`. + Migrating from the previous `replace go.mau.fi/whatsmeow => github.com/polymorfa/hypermeow` setup: drop the `replace` line, add a normal `require` on `github.com/polymorfa/hypermeow`, and rewrite `go.mau.fi/whatsmeow` import paths to `github.com/polymorfa/hypermeow`. A `replace` directive is only honoured in the main module, so the previous arrangement did not carry to anything that depended on your module in turn; a direct requirement does. The reproducible Barback and PostgreSQL benchmark is documented in [`benchmark/barback`](benchmark/barback/README.md). From 6eaf52956e918a3d5ea558c2edebee5a260899db Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 23:19:55 +0300 Subject: [PATCH 157/163] docs: use 'go mod why -m' for the coexistence check Without -m the query is about the package, so a dependency importing only a subpackage such as proto/waCommon reports that the module is not needed while it is in fact linked. Add a link-graph check as well. --- README.md | 12 ++++++++++-- go.sum | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8761e4a8..8e1106d75 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,18 @@ The old `replace` arrangement made this impossible, because both import paths re The failure is loud and immediate rather than silent, but it surfaces at process start. Check for it at build time instead: ```sh -go mod why go.mau.fi/whatsmeow # should report the module is not needed +go mod why -m go.mau.fi/whatsmeow # should report the module is not needed ``` -or assert it in a test via `debug.ReadBuildInfo()`, failing if any dependency reports the path `go.mau.fi/whatsmeow`. +Use `-m`. Without it, `go mod why` asks about the *package* `go.mau.fi/whatsmeow`, and a dependency that imports only a subpackage — say `go.mau.fi/whatsmeow/proto/waCommon` — makes it answer "main module does not need package" while the upstream module is linked and its descriptors still collide. + +A stricter check inspects the link graph directly: + +```sh +go list -deps ./... | grep '^go\.mau\.fi/whatsmeow' # must print nothing +``` + +or assert it in a test via `debug.ReadBuildInfo()`, failing if any entry in `Deps` reports the module path `go.mau.fi/whatsmeow`. Migrating from the previous `replace go.mau.fi/whatsmeow => github.com/polymorfa/hypermeow` setup: drop the `replace` line, add a normal `require` on `github.com/polymorfa/hypermeow`, and rewrite `go.mau.fi/whatsmeow` import paths to `github.com/polymorfa/hypermeow`. A `replace` directive is only honoured in the main module, so the previous arrangement did not carry to anything that depended on your module in turn; a direct requirement does. diff --git a/go.sum b/go.sum index 24e9dd851..3ef98c88c 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,12 @@ github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -26,10 +28,12 @@ github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/ github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca h1:GHSUVE4yOgX4E7kTRzpxCPbCOYkd3Kj8Dgdod30OI1E= github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11 h1:ZeZ6sUkmZY+v6ULce+O7cuuzUmvu9d3t5sY2CTrJ2AQ= github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11/go.mod h1:B5KZPsgswdf1eCvC+PZ7z45g5OECgqy6Zb0o7I5CcNQ= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -44,6 +48,7 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -51,8 +56,12 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 92b49be16dc3508188dd11813f3d5bdd0d3fae38 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 10 Aug 2026 23:43:27 +0300 Subject: [PATCH 158/163] docs: negate the coexistence grep so unsafe graphs fail grep exits 0 on a match, so the documented command succeeded precisely when upstream whatsmeow was in the graph. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e1106d75..beb3e9292 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,11 @@ Use `-m`. Without it, `go mod why` asks about the *package* `go.mau.fi/whatsmeow A stricter check inspects the link graph directly: ```sh -go list -deps ./... | grep '^go\.mau\.fi/whatsmeow' # must print nothing +! go list -deps ./... | grep -q '^go\.mau\.fi/whatsmeow' ``` +The negation matters if you put this in CI. `grep` exits 0 when it *finds* a match, so without the `!` the step would succeed exactly when the graph is unsafe and fail when it is clean. As written, exit 0 means safe. + or assert it in a test via `debug.ReadBuildInfo()`, failing if any entry in `Deps` reports the module path `go.mau.fi/whatsmeow`. Migrating from the previous `replace go.mau.fi/whatsmeow => github.com/polymorfa/hypermeow` setup: drop the `replace` line, add a normal `require` on `github.com/polymorfa/hypermeow`, and rewrite `go.mau.fi/whatsmeow` import paths to `github.com/polymorfa/hypermeow`. A `replace` directive is only honoured in the main module, so the previous arrangement did not carry to anything that depended on your module in turn; a direct requirement does. From b86af7604f9f6ab82ad1486fd87d4af9baf599e1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 11 Aug 2026 00:05:06 +0300 Subject: [PATCH 159/163] docs: do not let a failed go list read as a clean check --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index beb3e9292..6b284a974 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,13 @@ Use `-m`. Without it, `go mod why` asks about the *package* `go.mau.fi/whatsmeow A stricter check inspects the link graph directly: ```sh -! go list -deps ./... | grep -q '^go\.mau\.fi/whatsmeow' +deps="$(go list -deps ./...)" && ! grep -q '^go\.mau\.fi/whatsmeow' <<<"$deps" ``` -The negation matters if you put this in CI. `grep` exits 0 when it *finds* a match, so without the `!` the step would succeed exactly when the graph is unsafe and fail when it is clean. As written, exit 0 means safe. +Exit 0 means safe. Both halves of that command are load-bearing in CI: + +- the `!` is needed because `grep` exits 0 when it *finds* a match, so without it the step would succeed exactly when the graph is unsafe; +- `go list` is kept out of the negated pipeline because, piped directly, a failed `go list` produces no output, `grep` then exits nonzero for want of a match, and the `!` would turn "the graph could not be loaded" into a pass. or assert it in a test via `debug.ReadBuildInfo()`, failing if any entry in `Deps` reports the module path `go.mau.fi/whatsmeow`. From 45788c789f523ddf6c929f78cab3e99d89a3ba7a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 11 Aug 2026 00:14:02 +0300 Subject: [PATCH 160/163] docs: scan test deps and keep the coexistence check POSIX --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6b284a974..a10f5e30d 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,16 @@ Use `-m`. Without it, `go mod why` asks about the *package* `go.mau.fi/whatsmeow A stricter check inspects the link graph directly: ```sh -deps="$(go list -deps ./...)" && ! grep -q '^go\.mau\.fi/whatsmeow' <<<"$deps" +deps="$(go list -deps -test ./...)" && + ! printf '%s\n' "$deps" | grep -q '^go\.mau\.fi/whatsmeow' ``` -Exit 0 means safe. Both halves of that command are load-bearing in CI: +Exit 0 means safe. Every part of that command is load-bearing in CI: +- `-test` is needed because `go list -deps` omits test-only dependencies, and a project that imports upstream only from a `_test.go` file still links both copies when `go test` builds; - the `!` is needed because `grep` exits 0 when it *finds* a match, so without it the step would succeed exactly when the graph is unsafe; -- `go list` is kept out of the negated pipeline because, piped directly, a failed `go list` produces no output, `grep` then exits nonzero for want of a match, and the `!` would turn "the graph could not be loaded" into a pass. +- `go list` is kept out of the negated pipeline because, piped directly, a failed `go list` produces no output, `grep` then exits nonzero for want of a match, and the `!` would turn "the graph could not be loaded" into a pass; +- `printf` rather than a `<<<` here-string, so the snippet still parses under a POSIX `/bin/sh` such as dash. or assert it in a test via `debug.ReadBuildInfo()`, failing if any entry in `Deps` reports the module path `go.mau.fi/whatsmeow`. From 2a5d862c0d26480a9eea402c3db7b93605aafd33 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 11 Aug 2026 00:24:42 +0300 Subject: [PATCH 161/163] docs: drop the pipeline from the coexistence check --- README.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a10f5e30d..ee8f58606 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,11 @@ Use `-m`. Without it, `go mod why` asks about the *package* `go.mau.fi/whatsmeow A stricter check inspects the link graph directly: ```sh -deps="$(go list -deps -test ./...)" && - ! printf '%s\n' "$deps" | grep -q '^go\.mau\.fi/whatsmeow' +deps="$(go list -deps -test ./...)" || exit 1 +case "$deps" in *go.mau.fi/whatsmeow*) exit 1 ;; esac ``` -Exit 0 means safe. Every part of that command is load-bearing in CI: - -- `-test` is needed because `go list -deps` omits test-only dependencies, and a project that imports upstream only from a `_test.go` file still links both copies when `go test` builds; -- the `!` is needed because `grep` exits 0 when it *finds* a match, so without it the step would succeed exactly when the graph is unsafe; -- `go list` is kept out of the negated pipeline because, piped directly, a failed `go list` produces no output, `grep` then exits nonzero for want of a match, and the `!` would turn "the graph could not be loaded" into a pass; -- `printf` rather than a `<<<` here-string, so the snippet still parses under a POSIX `/bin/sh` such as dash. +Exit 0 means safe. There is deliberately no pipeline and no external command here. `-test` matters because `go list -deps` omits test-only dependencies, and a project importing upstream only from a `_test.go` still links both copies under `go test`. Capturing the output first means a failed `go list` propagates through `||` instead of being mistaken for an empty result, and matching with `case` avoids `grep`, whose exit status is 0 on a match — so a naive form of this check passes precisely when the graph is unsafe. or assert it in a test via `debug.ReadBuildInfo()`, failing if any entry in `Deps` reports the module path `go.mau.fi/whatsmeow`. From aeb9eb213ef81efb8d9cba83b14d21032d2d7b6c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 11 Aug 2026 00:34:17 +0300 Subject: [PATCH 162/163] build: tidy go.sum The go-mod-tidy pre-commit hook fails on nine stale entries for modules no longer in the graph. --- go.sum | 9 --------- 1 file changed, 9 deletions(-) diff --git a/go.sum b/go.sum index 3ef98c88c..24e9dd851 100644 --- a/go.sum +++ b/go.sum @@ -10,12 +10,10 @@ github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -28,12 +26,10 @@ github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/ github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca h1:GHSUVE4yOgX4E7kTRzpxCPbCOYkd3Kj8Dgdod30OI1E= github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11 h1:ZeZ6sUkmZY+v6ULce+O7cuuzUmvu9d3t5sY2CTrJ2AQ= github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11/go.mod h1:B5KZPsgswdf1eCvC+PZ7z45g5OECgqy6Zb0o7I5CcNQ= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -48,7 +44,6 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -56,12 +51,8 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From e2f25d633d9015d20691e0549925c1705830a00d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 11 Aug 2026 02:05:33 +0300 Subject: [PATCH 163/163] fix(bench): support legacy module baselines --- benchmark/barback/Dockerfile | 2 +- benchmark/barback/Dockerfile.clientmem | 2 +- benchmark/barback/prepare-library-module.sh | 22 +++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 benchmark/barback/prepare-library-module.sh diff --git a/benchmark/barback/Dockerfile b/benchmark/barback/Dockerfile index 218d51fd5..7a10afd31 100644 --- a/benchmark/barback/Dockerfile +++ b/benchmark/barback/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /src COPY . . COPY --from=library . /library WORKDIR /src/benchmark/barback -RUN go mod edit -replace=github.com/polymorfa/hypermeow=/library +RUN sh ./prepare-library-module.sh /library RUN --mount=type=cache,target=/go/pkg/mod go mod tidy RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -tags="${BENCH_BUILD_TAGS}" -trimpath -ldflags="-s -w -X main.revision=${BUILD_REV}" -o /out/hypermeow-bench ./cmd/bench diff --git a/benchmark/barback/Dockerfile.clientmem b/benchmark/barback/Dockerfile.clientmem index 5e2d140ac..505f5f73f 100644 --- a/benchmark/barback/Dockerfile.clientmem +++ b/benchmark/barback/Dockerfile.clientmem @@ -6,7 +6,7 @@ WORKDIR /src COPY . . COPY --from=library . /library WORKDIR /src/benchmark/barback -RUN go mod edit -replace=github.com/polymorfa/hypermeow=/library +RUN sh ./prepare-library-module.sh /library RUN --mount=type=cache,target=/go/pkg/mod go mod tidy RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.revision=${BUILD_REV}" -o /out/clientmem ./cmd/clientmem diff --git a/benchmark/barback/prepare-library-module.sh b/benchmark/barback/prepare-library-module.sh new file mode 100644 index 000000000..498d07017 --- /dev/null +++ b/benchmark/barback/prepare-library-module.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +library_dir=${1:?library directory is required} +module_path=$(sed -n 's/^module[[:space:]]\{1,\}//p' "$library_dir/go.mod" | head -n 1) + +case "$module_path" in + github.com/polymorfa/hypermeow) + go mod edit -replace=github.com/polymorfa/hypermeow="$library_dir" + ;; + go.mau.fi/whatsmeow) + find cmd -type f -name '*.go' -exec sed -i 's#github.com/polymorfa/hypermeow#go.mau.fi/whatsmeow#g' {} + + go mod edit -dropreplace=github.com/polymorfa/hypermeow + go mod edit -droprequire=github.com/polymorfa/hypermeow + go mod edit -require=go.mau.fi/whatsmeow@v0.0.0 + go mod edit -replace=go.mau.fi/whatsmeow="$library_dir" + ;; + *) + echo "unsupported library module: $module_path" >&2 + exit 2 + ;; +esac