diff --git a/.dockerignore b/.dockerignore index 78fb4f0c0..fc4b2464b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,7 @@ +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + .git benchmark/barback/results +benchmark/barback/testdata/results diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index f80d6b032..6b0f09512 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,5 @@ +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + github: [purpshell] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..bb98c0276 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ + + +# Changelog + +All notable HyperMeow changes are documented here. HyperMeow uses commit pseudo-versions from its reviewed `main` branch. + +## [Unreleased] + +### Documentation + +- Added an evidence-backed comparison with upstream WhatsMeow. +- Consolidated the root white-box test suite into themed files without removing tests or changing coverage. +- Consolidated handwritten business-app features into `business.go` without changing the exported API. +- Moved committed benchmark reports and heap profiles into `benchmark/barback/testdata/results`; fresh run output remains under the ignored `benchmark/barback/results` workspace. +- Retracted the accidental `v0.1.0` tag and its correction carrier. +- Kept `v0.0.0` as the only selectable tag; use `@main` for the newest reviewed commit. +- Established `main` as the sole package source; `dev` remains an integration branch. + +## First public release - 2026-08-10 + +The first HyperMeow release combines the reviewed `dev` train with upstream WhatsMeow protocol updates through protobuf revision `v1044834443`. + +### Added + +- Published the standalone `github.com/polymorfa/hypermeow` module while retaining the upstream `whatsmeow` package names. +- Added raw-node compatibility hooks for integrations that need protocol-level observation. +- Added business linked-account and feature-eligibility reads. +- Added business profile, cover photo, product, collection, catalog, cart, visibility, appeal, and merchant-compliance mutations. +- Added validated business message builders for product lists, orders, addresses, lists, and native Flows. +- Added native Flow response metadata handling and exact preservation of JSON number lexemes. +- Added newsletter deletion through generated MEX bindings. +- Added quick-reply app-state actions and events. +- Added atomic label replacement and configurable label/full-sync event emission. +- Added independent history-sync receipt, persistence, and media-deletion controls. +- Added phone-number consent request/share message builders. +- Added LID identity verification-code generation. +- Added username persistence and PN/username-to-LID alias resolution across contacts, groups, notifications, and group membership changes. +- Added optional batched reverse-LID lookup support with bounded query chunks and negative caching. +- Added a reproducible Barback/PostgreSQL benchmark suite for DM, group, history, media, mixed messages, client memory, saturation, phone consent, business operations, and security codes. + +### Changed + +- Made LIDs the primary Signal identity while retaining phone numbers and usernames as aliases when available. +- Reworked retry-message storage to allocate lazily and retain encoded payloads instead of full protobuf object graphs. +- Added bounded group, device, contact, identity, migration, retry, app-state-key, and PN-to-LID caches with durable stores remaining authoritative. +- Batched PostgreSQL session, identity, message-secret, and alias operations while retaining atomic fallbacks for generic SQL drivers. +- Avoided empty PN-to-LID database transactions and added PostgreSQL pattern indexes for migration-prefix lookups. +- Shared the default HTTP transport across clients while preserving isolation for custom proxy and HTTP-client configuration. +- Preserved the newest history-sync nonce through asynchronous persistence without blocking event delivery. + +### Reliability + +- Hardened malformed and odd-length binary node decoding. +- Serialized device save/delete operations and made overlapping history-sync nonce writes monotonic. +- Exposed participant-hash mismatches instead of silently accepting inconsistent group state. +- Added bounded handler queues and guarded reconnect behavior at overflow. +- Replaced unsupported Signal-store panics with returned errors. +- Added explicit error handling for receipt misuse, CBC invariant failures, missing signed prekeys, upload read failures, partial writes, and temporary-file cleanup. +- Added compatibility checks and Docker build support for archived WhatsMeow and pre-optimization Barback baselines after the module-path migration. + +### Security and privacy + +- Redacted business access tokens, cookies, nonces, profile fields, and linked-account payloads from binary-node logs. +- Added strict validation for business payload lengths, prices, product/section counts, mixed native-flow metadata, UTF-8, and trailing JSON. +- Added privacy-cache completeness and bounded failure behavior. +- Added atomic Signal identity insertion and deletion-generation fencing to prevent stale prekey fetches from restoring deleted identities. + +### Performance evidence + +- The frozen three-repeat system matrix completed 45 bounded workloads with no send failures, queue overflows, or temporary files left behind. +- Against the recorded upstream revision, HyperMeow reduced allocation in all five system scenarios, reduced group-128 send p95 by 79.3%, reduced group-128 client CPU by 58.2%, and reduced group-128 SQL calls by 97.9%. +- The isolated 2,000-client constructor benchmark measured 4,240 bytes of Go heap per HyperMeow client versus 78,929 bytes upstream, a 94.6% reduction in disconnected fixed state. +- The encrypted ping-pong test sustained 1,700 healthy pairs per second versus 900 upstream under the same bounded local environment. + +See the [system comparison](benchmark/barback/testdata/results/system-comparison.md), [RAM hardening report](benchmark/barback/testdata/results/ram-hardening.md), and [saturation report](benchmark/barback/testdata/results/maxrate-ping-pong.md) for revisions, limits, caveats, and raw evidence. + +### Compatibility notes + +- Applications must migrate imports from `go.mau.fi/whatsmeow` to `github.com/polymorfa/hypermeow`. +- A single binary must not link both modules because they register identical generated protobuf descriptors. +- The Polymorfa Signal dependency changes exported Signal parameter types relative to upstream; other exported differences in this release are additive. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 000000000..22cd4182c --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rajeh Taher + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 000000000..c641da39d --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,25 @@ + + +# Licensing + +HyperMeow preserves the licenses and copyright notices of its sources. + +- Files and source content inherited from `tulir/whatsmeow` history, including + material later removed upstream and Polymorfa modifications within those + files, remain under the Mozilla Public License 2.0 in [LICENSE](LICENSE). +- Files introduced by Polymorfa and authored by Rajeh Taher are under the MIT + License in [LICENSE-MIT](LICENSE-MIT). +- Generated files, benchmark reports, and other non-commentable artifacts added + by Polymorfa follow the same MIT grant without requiring an embedded header. +- Embedded third-party material retains its original copyright and license. + +New Polymorfa-authored source files use this notice: + +```text +Copyright (c) 2026 Rajeh Taher + +Licensed under the MIT License. See LICENSE-MIT for details. +``` diff --git a/README.md b/README.md index ee8f58606..0ac896068 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,125 @@ # HyperMeow + [![Go Reference](https://pkg.go.dev/badge/github.com/polymorfa/hypermeow.svg)](https://pkg.go.dev/github.com/polymorfa/hypermeow) +[![Go](https://github.com/polymorfa/hypermeow/actions/workflows/go.yml/badge.svg?branch=main)](https://github.com/polymorfa/hypermeow/actions/workflows/go.yml) -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. +HyperMeow is Polymorfa's production-focused fork of [tulir/whatsmeow](https://github.com/tulir/whatsmeow). It keeps the upstream Go package names and protocol foundation while adding the business-app surface, LID-first identity model, bounded state, PostgreSQL efficiency, and reliability controls needed for large multi-session deployments. -HyperMeow is its own Go module, imported directly as `github.com/polymorfa/hypermeow`. It no longer requires a `replace` directive: +The upstream project remains the smaller choice for applications that only need its core WhatsApp messaging scope. HyperMeow deliberately accepts a broader API and maintenance surface in exchange for the capabilities below. -```go -import whatsmeow "github.com/polymorfa/hypermeow" -``` +## Why HyperMeow instead of upstream WhatsMeow? -```sh -go get github.com/polymorfa/hypermeow -``` +This comparison was last verified against upstream `main` at [`a23afe3`](https://github.com/tulir/whatsmeow/commit/a23afe3171803f34d6761979988b9d2275e961c7) on 2026-08-11. Each upstream sync must update this section when the difference changes. -### The module is `hypermeow`, the package is `whatsmeow` +| Area | HyperMeow advantage over upstream `main` | +| --- | --- | +| Business app | Adds linked-account and eligibility reads, business-profile and cover-photo mutation, product and collection mutation, catalog creation, cart and visibility controls, appeal and merchant-compliance operations, and validated business message builders. | +| Native Flows | Adds typed address, list, order, and Flow message builders with payload limits, exact JSON-number preservation, UTF-8 validation, and response metadata handling. | +| Identity | Treats LIDs as the stable Signal identity, persists PN and username aliases, resolves aliases in batches, exposes phone-number consent messages, and generates LID identity verification codes. | +| App state | Adds atomic label replacement, label and quick-reply events, full-sync event controls, and independent history-sync receipt, storage, and media-deletion policies. | +| Channels | Adds newsletter deletion through generated MEX bindings. | +| Reliability | Hardens malformed binary-node handling, serializes device save/delete operations, surfaces participant-hash mismatches, bounds attacker-influenced caches, and redacts sensitive business payloads from node logs. | +| PostgreSQL | Batches Signal and metadata work, avoids empty PN-to-LID transactions, adds indexed alias lookups, and keeps PostgreSQL authoritative after bounded cache eviction. | +| Validation | Ships a reproducible Barback/PostgreSQL harness covering DM, group, history, media, mixed-message, security-code, phone-consent, resource, and saturation workloads. | -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: +### Measured system advantages -- 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. +The repository retains the raw reports and methodology behind these numbers in [`benchmark/barback/testdata/results`](benchmark/barback/testdata/results). They compare frozen revisions under the same local Barback, PostgreSQL, TLS, Noise, CPU, and memory constraints. They are engineering comparisons, not WhatsApp WAN or account-rate-limit claims. -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). +| Measurement | Upstream WhatsMeow | HyperMeow | Result | +| --- | ---: | ---: | ---: | +| Disconnected constructor heap, 2,000 clients | 78,929 B/client | 4,240 B/client | 94.6% less Go heap | +| Group-128 send p95 | 33.998 ms | 7.024 ms | 79.3% lower | +| Group-128 client CPU | 5.502 s | 2.301 s | 58.2% lower | +| Group-128 SQL calls | 165,053 | 3,456 | 97.9% fewer | +| Highest healthy encrypted ping-pong rate | 900 pairs/s | 1,700 pairs/s | 1.89x the rate | -### Only one whatsmeow may be linked into a binary +The complete three-repeat system matrix documents allocation, RSS, CPU, latency, PostgreSQL, network, and I/O results across five workloads. It also records the trade-offs: history-heavy peak RSS was slightly higher in one comparison, and active-session capacity depends on contacts, groups, media, handlers, and application queues rather than constructor memory alone. -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`: +- [System comparison](benchmark/barback/testdata/results/system-comparison.md) +- [RAM hardening](benchmark/barback/testdata/results/ram-hardening.md) +- [Ping-pong saturation](benchmark/barback/testdata/results/maxrate-ping-pong.md) +- [Benchmark instructions](benchmark/barback/README.md) -``` -proto: file "waCommon/WACommon.proto" is already registered +## Install + +Install the newest reviewed commit from the authoritative `main` branch: + +```sh +go get github.com/polymorfa/hypermeow@main ``` -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 `main` query resolves to a commit pseudo-version. After the version correction, +`v0.0.0` is the only selectable tagged version. `dev` is an integration branch and +is never a publication source. -The failure is loud and immediate rather than silent, but it surfaces at process start. Check for it at build time instead: +The root package remains named `whatsmeow`, so use an explicit import alias: -```sh -go mod why -m go.mau.fi/whatsmeow # should report the module is not needed +```go +import whatsmeow "github.com/polymorfa/hypermeow" ``` -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. +## Migrating from WhatsMeow -A stricter check inspects the link graph directly: +Replace `go.mau.fi/whatsmeow` imports with `github.com/polymorfa/hypermeow` and remove any old module replacement: ```sh -deps="$(go list -deps -test ./...)" || exit 1 -case "$deps" in *go.mau.fi/whatsmeow*) exit 1 ;; esac +go mod edit -dropreplace=go.mau.fi/whatsmeow +go get github.com/polymorfa/hypermeow@main +go mod tidy ``` -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. +## Core compatibility + +HyperMeow retains upstream's core support for: + +- private, group, media, and status messages; +- group management and invite links; +- typing, delivery, and read receipts; +- app-state synchronization; +- retry receipts and message decryption recovery. + +New exported functionality is additive unless called out in the [changelog](CHANGELOG.md). + +## Discord -or assert it in a test via `debug.ReadBuildInfo()`, failing if any entry in `Deps` reports the module path `go.mau.fi/whatsmeow`. +Join the [WhatsApp Web ecosystem Discord](https://whiskey.so/discord) and visit +`#hypermeow` for project discussion. -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. +## Get support -The reproducible Barback and PostgreSQL benchmark is documented in [`benchmark/barback`](benchmark/barback/README.md). +If you'd like business to enterprise-level support from Rajeh, you can book a video +chat. Book a 1 hour time slot by contacting him on Discord or pre-ordering +[here](https://purpshell.dev/book). The earlier you pre-order the better, as his +time slots usually fill up very quickly. -## Discussion +If you are a business, we encourage you to contribute back to the development costs +of the project. You can do so by booking meetings or sponsoring below. All support +is welcome from businesses of all sizes. -Discord server (#hypermeow channel): https://whiskey.so/discord +## Sponsor -## Usage +If you'd like to financially support this project, you can do so +[here](https://purpshell.dev/sponsor). -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. +## Disclaimer -## Features +> [!CAUTION] +> This project is not affiliated, associated, authorized, endorsed by, or in any way +> officially connected with WhatsApp or any of its subsidiaries or its affiliates. +> The official WhatsApp website can be found at whatsapp.com. "WhatsApp" as well as +> related names, marks, emblems and images are registered trademarks of their +> respective owners. +> +> The maintainers do not condone using this project in practices that violate the +> Terms of Service of WhatsApp, and call upon users to use it fairly. -Most core features are already present: +## License -* Sending messages to private chats and groups (both text and media) -* Receiving all messages -* Managing groups and receiving group change events -* Joining via invite messages, using and creating invite links -* Sending and receiving typing notifications -* Sending and receiving delivery and read receipts -* Reading and writing app state (contact list, chat pin/mute status, etc) -* Sending and handling retry receipts if message decryption fails -* Sending status messages (experimental, may not work for large contact lists) +Upstream-derived files retain their original Tulir Asokan and contributor +copyright notices and remain licensed under [MPL-2.0](LICENSE). -Things that are not yet implemented: +Polymorfa-added files: Copyright (c) 2026 Rajeh Taher, licensed under the +[MIT License](LICENSE-MIT). -* Sending broadcast list messages (this is not supported on WhatsApp web either) -* Calls +See [LICENSING.md](LICENSING.md) for the file-level boundary. diff --git a/appstate/encode_label_test.go b/appstate/encode_label_test.go index 8878b0822..eeb0f5ffa 100644 --- a/appstate/encode_label_test.go +++ b/appstate/encode_label_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package appstate import ( diff --git a/appstate/encode_quick_reply_test.go b/appstate/encode_quick_reply_test.go index 964459def..050040f8c 100644 --- a/appstate/encode_quick_reply_test.go +++ b/appstate/encode_quick_reply_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package appstate import ( diff --git a/historysync_test.go b/appstate_history_test.go similarity index 62% rename from historysync_test.go rename to appstate_history_test.go index 33933f297..1ab554c58 100644 --- a/historysync_test.go +++ b/appstate_history_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package whatsmeow import ( @@ -10,12 +14,73 @@ import ( "google.golang.org/protobuf/proto" + "github.com/polymorfa/hypermeow/appstate" waE2E "github.com/polymorfa/hypermeow/proto/waE2E" waHistorySync "github.com/polymorfa/hypermeow/proto/waHistorySync" + "github.com/polymorfa/hypermeow/proto/waServerSync" + "github.com/polymorfa/hypermeow/proto/waSyncAction" "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" waLog "github.com/polymorfa/hypermeow/util/log" ) +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") + } + 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) + } +} + type historySyncDeviceContainer struct { putContextErr chan error putRelease chan struct{} @@ -239,3 +304,32 @@ func TestAsyncHistorySyncNoncePersistenceKeepsNewestNonce(t *testing.T) { t.Fatalf("persisted companion meta nonce = %q", nonce) } } + +func TestApplyPrivacySettingUpdatesEveryCategory(t *testing.T) { + tests := []struct { + name types.PrivacySettingType + value types.PrivacySetting + get func(types.PrivacySettings) types.PrivacySetting + }{ + {types.PrivacySettingTypeGroupAdd, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.GroupAdd }}, + {types.PrivacySettingTypeLastSeen, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.LastSeen }}, + {types.PrivacySettingTypeStatus, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.Status }}, + {types.PrivacySettingTypeProfile, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.Profile }}, + {types.PrivacySettingTypeReadReceipts, types.PrivacySettingNone, func(s types.PrivacySettings) types.PrivacySetting { return s.ReadReceipts }}, + {types.PrivacySettingTypeOnline, types.PrivacySettingMatchLastSeen, func(s types.PrivacySettings) types.PrivacySetting { return s.Online }}, + {types.PrivacySettingTypeCallAdd, types.PrivacySettingKnown, func(s types.PrivacySettings) types.PrivacySetting { return s.CallAdd }}, + {types.PrivacySettingTypeMessages, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.Messages }}, + {types.PrivacySettingTypeDefense, types.PrivacySettingOnStandard, func(s types.PrivacySettings) types.PrivacySetting { return s.Defense }}, + {types.PrivacySettingTypeStickers, types.PrivacySettingContactAllowlist, func(s types.PrivacySettings) types.PrivacySetting { return s.Stickers }}, + } + + for _, test := range tests { + t.Run(string(test.name), func(t *testing.T) { + var settings types.PrivacySettings + applyPrivacySetting(&settings, test.name, test.value) + if actual := test.get(settings); actual != test.value { + t.Fatalf("setting = %q, want %q", actual, test.value) + } + }) + } +} diff --git a/appstate_label_events_test.go b/appstate_label_events_test.go deleted file mode 100644 index 057859a8a..000000000 --- a/appstate_label_events_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package whatsmeow - -import ( - "context" - "testing" - "time" - - "google.golang.org/protobuf/proto" - - "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) { - 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") - } - 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/benchmark/barback/.env.example b/benchmark/barback/.env.example index 72581474b..79322e7af 100644 --- a/benchmark/barback/.env.example +++ b/benchmark/barback/.env.example @@ -1,3 +1,7 @@ +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + BENCH_GROUP_SIZE=128 BENCH_MODE=group BENCH_MESSAGE_PROFILE=text diff --git a/benchmark/barback/Dockerfile b/benchmark/barback/Dockerfile index 7a10afd31..328690ebc 100644 --- a/benchmark/barback/Dockerfile +++ b/benchmark/barback/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. FROM golang:1.26-bookworm@sha256:6c5605ab3a9a9fb3c4eafe5b3d63cdbf3881caf113262b67862547b54a9db599 AS build ARG BUILD_REV=working-tree diff --git a/benchmark/barback/Dockerfile.clientmem b/benchmark/barback/Dockerfile.clientmem index 505f5f73f..586db59a5 100644 --- a/benchmark/barback/Dockerfile.clientmem +++ b/benchmark/barback/Dockerfile.clientmem @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7 +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. FROM golang:1.26-bookworm AS build ARG BUILD_REV=working-tree diff --git a/benchmark/barback/README.md b/benchmark/barback/README.md index 0d0b260eb..4b220e897 100644 --- a/benchmark/barback/README.md +++ b/benchmark/barback/README.md @@ -1,3 +1,8 @@ + + # HyperMeow Barback benchmark This benchmark pairs a real WhatsMeow client with Barback, stores its device and Signal state in PostgreSQL, and drives DM or group messages through the full Noise, Signal, receipt, sender-key, and history-sync paths. @@ -67,13 +72,13 @@ BENCH_VARIANT=hypermeow BENCH_SESSIONS=2000 ./run-client-memory.sh `LIBRARY_CONTEXT` can point to another HyperMeow worktree to compare two revisions without changing the benchmark code or branches. Barback generates a persisted TLS certificate for each clean stack. The client trusts that certificate and keeps both TLS and Noise certificate verification enabled. -Results are written to `results/`. PostgreSQL statement statistics are reset on the authenticated connection event, before Barback's benchmark warmup. The report includes the top WhatsMeow queries, total statement calls and execution time, send and upload latency percentiles, throughput, Go heap/GC/CPU data, peak RSS, process and block I/O, temporary-file peaks and cleanup, network traffic, failures, message-shape counts, and history-sync counts. `session_runtime` begins at the connected event and includes history sync; `workload_runtime` begins at the first live benchmark message. Docker stats include the whole container lifetime. Network timings are local-container transport measurements, not internet latency. +Fresh results are written to the ignored `results/` workspace. PostgreSQL statement statistics are reset on the authenticated connection event, before Barback's benchmark warmup. The report includes the top WhatsMeow queries, total statement calls and execution time, send and upload latency percentiles, throughput, Go heap/GC/CPU data, peak RSS, process and block I/O, temporary-file peaks and cleanup, network traffic, failures, message-shape counts, and history-sync counts. `session_runtime` begins at the connected event and includes history sync; `workload_runtime` begins at the first live benchmark message. Docker stats include the whole container lifetime. Network timings are local-container transport measurements, not internet latency. When `BENCH_WORKERS` is greater than one, messages are assigned to chat-affine worker queues. Different chats run concurrently, while messages for one Signal session remain ordered. Set `BARBACK_LOG_LEVEL=info` when validating ping-pong throughput so the final decrypted-pong count and wire RTT are visible in the Barback logs. -The frozen three-repeat comparison is summarized in `results/system-comparison.md`; its 45 JSON reports and 45 Docker-stat streams remain alongside it. +The frozen three-repeat comparison is summarized in `testdata/results/system-comparison.md`; its versioned JSON reports and Docker-stat streams remain alongside it. -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`. +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`. Move only deliberately retained evidence into `testdata/results/`. 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. diff --git a/benchmark/barback/cmd/bench/business_app_smoke.go b/benchmark/barback/cmd/bench/business_app_smoke.go index 6e1e23a2f..db222f102 100644 --- a/benchmark/barback/cmd/bench/business_app_smoke.go +++ b/benchmark/barback/cmd/bench/business_app_smoke.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + //go:build !benchmark_legacy package main diff --git a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go index 4822a6b7e..a750e77a1 100644 --- a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go +++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + //go:build benchmark_legacy package main 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 9000831c8..da789d734 100644 --- a/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go +++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + //go:build benchmark_legacy package main diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go index d3a267774..89fbc6851 100644 --- a/benchmark/barback/cmd/bench/main.go +++ b/benchmark/barback/cmd/bench/main.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package main import ( diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go index cc01df272..cb726f070 100644 --- a/benchmark/barback/cmd/bench/main_test.go +++ b/benchmark/barback/cmd/bench/main_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package main import ( diff --git a/benchmark/barback/cmd/bench/phone_consent_sync.go b/benchmark/barback/cmd/bench/phone_consent_sync.go index ac6a1eafb..28cfe1e65 100644 --- a/benchmark/barback/cmd/bench/phone_consent_sync.go +++ b/benchmark/barback/cmd/bench/phone_consent_sync.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + //go:build !benchmark_legacy package main diff --git a/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go b/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go index 3ed971349..8c2a13981 100644 --- a/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go +++ b/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + //go:build benchmark_legacy package main diff --git a/benchmark/barback/cmd/bench/resource_metrics.go b/benchmark/barback/cmd/bench/resource_metrics.go index 4d9f6cbda..332f95479 100644 --- a/benchmark/barback/cmd/bench/resource_metrics.go +++ b/benchmark/barback/cmd/bench/resource_metrics.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package main import ( diff --git a/benchmark/barback/cmd/bench/security_code_smoke.go b/benchmark/barback/cmd/bench/security_code_smoke.go index 50f1866c7..6597f723b 100644 --- a/benchmark/barback/cmd/bench/security_code_smoke.go +++ b/benchmark/barback/cmd/bench/security_code_smoke.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + //go:build !benchmark_legacy package main diff --git a/benchmark/barback/cmd/bench/security_code_smoke_legacy.go b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go index 437d488be..b95789d43 100644 --- a/benchmark/barback/cmd/bench/security_code_smoke_legacy.go +++ b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + //go:build benchmark_legacy package main 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 8ba5fd4aa..762875615 100644 --- a/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go +++ b/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + //go:build benchmark_legacy package main diff --git a/benchmark/barback/cmd/bench/workload_messages.go b/benchmark/barback/cmd/bench/workload_messages.go index 44208ac4d..598fe4768 100644 --- a/benchmark/barback/cmd/bench/workload_messages.go +++ b/benchmark/barback/cmd/bench/workload_messages.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package main import ( diff --git a/benchmark/barback/cmd/clientmem/main.go b/benchmark/barback/cmd/clientmem/main.go index fc0a7ed3e..c56c08efe 100644 --- a/benchmark/barback/cmd/clientmem/main.go +++ b/benchmark/barback/cmd/clientmem/main.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package main import ( diff --git a/benchmark/barback/comparison-matrix-env.sh b/benchmark/barback/comparison-matrix-env.sh index 161edeb54..5d0cb8a8a 100644 --- a/benchmark/barback/comparison-matrix-env.sh +++ b/benchmark/barback/comparison-matrix-env.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + comparison_security_code_smoke() { local name=$1 build_tags=$2 requested=$3 diff --git a/benchmark/barback/comparison_matrix_test.go b/benchmark/barback/comparison_matrix_test.go index 04ee6ac3a..9624533c6 100644 --- a/benchmark/barback/comparison_matrix_test.go +++ b/benchmark/barback/comparison_matrix_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package barback_test import ( diff --git a/benchmark/barback/compose.dm.yaml b/benchmark/barback/compose.dm.yaml index 459ee54c3..8b55387c9 100644 --- a/benchmark/barback/compose.dm.yaml +++ b/benchmark/barback/compose.dm.yaml @@ -1,3 +1,7 @@ +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + services: barback: command: !override diff --git a/benchmark/barback/compose.local-cache.yaml b/benchmark/barback/compose.local-cache.yaml index 5e8e561c9..bfce0067c 100644 --- a/benchmark/barback/compose.local-cache.yaml +++ b/benchmark/barback/compose.local-cache.yaml @@ -1,3 +1,7 @@ +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + services: barback: image: hypermeow-benchmark-barback:latest diff --git a/benchmark/barback/compose.yaml b/benchmark/barback/compose.yaml index 71d034677..340ff4ef2 100644 --- a/benchmark/barback/compose.yaml +++ b/benchmark/barback/compose.yaml @@ -1,3 +1,7 @@ +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + name: hypermeow-benchmark services: diff --git a/benchmark/barback/go.mod b/benchmark/barback/go.mod index 6e26db10c..a04271872 100644 --- a/benchmark/barback/go.mod +++ b/benchmark/barback/go.mod @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + module github.com/polymorfa/hypermeow/benchmark/barback go 1.25.0 diff --git a/benchmark/barback/postgres/init.sql b/benchmark/barback/postgres/init.sql index 841ff0c4b..8cad88a1e 100644 --- a/benchmark/barback/postgres/init.sql +++ b/benchmark/barback/postgres/init.sql @@ -1 +1,5 @@ +-- Copyright (c) 2026 Rajeh Taher +-- +-- Licensed under the MIT License. See LICENSE-MIT for details. + CREATE EXTENSION IF NOT EXISTS pg_stat_statements; diff --git a/benchmark/barback/prepare-library-module.sh b/benchmark/barback/prepare-library-module.sh index 498d07017..df271a563 100644 --- a/benchmark/barback/prepare-library-module.sh +++ b/benchmark/barback/prepare-library-module.sh @@ -1,4 +1,8 @@ #!/bin/sh +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + set -eu library_dir=${1:?library directory is required} diff --git a/benchmark/barback/results/.gitignore b/benchmark/barback/results/.gitignore new file mode 100644 index 000000000..0a05da042 --- /dev/null +++ b/benchmark/barback/results/.gitignore @@ -0,0 +1,7 @@ +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + +* +!.gitignore +!.gitkeep diff --git a/benchmark/barback/run-client-memory.sh b/benchmark/barback/run-client-memory.sh index 19967c919..716aaafee 100755 --- a/benchmark/barback/run-client-memory.sh +++ b/benchmark/barback/run-client-memory.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + set -euo pipefail benchmark_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) diff --git a/benchmark/barback/run-comparison-matrix.sh b/benchmark/barback/run-comparison-matrix.sh index d8ce250e5..433239f9e 100755 --- a/benchmark/barback/run-comparison-matrix.sh +++ b/benchmark/barback/run-comparison-matrix.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + set -euo pipefail benchmark_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) diff --git a/benchmark/barback/run-dm-matrix.sh b/benchmark/barback/run-dm-matrix.sh index dac2d2843..34a1bab34 100755 --- a/benchmark/barback/run-dm-matrix.sh +++ b/benchmark/barback/run-dm-matrix.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + set -euo pipefail benchmark_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) diff --git a/benchmark/barback/run-system-matrix.sh b/benchmark/barback/run-system-matrix.sh index 80272ff61..499efa571 100755 --- a/benchmark/barback/run-system-matrix.sh +++ b/benchmark/barback/run-system-matrix.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash +# Copyright (c) 2026 Rajeh Taher +# +# Licensed under the MIT License. See LICENSE-MIT for details. + set -euo pipefail benchmark_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) diff --git a/benchmark/barback/results/README.md b/benchmark/barback/testdata/results/README.md similarity index 95% rename from benchmark/barback/results/README.md rename to benchmark/barback/testdata/results/README.md index a01dfc098..fe499ec9d 100644 --- a/benchmark/barback/results/README.md +++ b/benchmark/barback/testdata/results/README.md @@ -1,3 +1,8 @@ + + # Group-128 secure benchmark Two clean runs per revision used the same Barback revision, PostgreSQL image, TLS and Noise verification, 2-vCPU/3.5-GB Compose limits, 128 additional group members, 200 messages at 50 messages per second, and four history-sync payloads containing 8,000 messages total. Every run sent and received all 200 messages with no send failures or queue overflows. diff --git a/benchmark/barback/results/baseline-dm-burst-16.json b/benchmark/barback/testdata/results/baseline-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/baseline-dm-burst-16.json rename to benchmark/barback/testdata/results/baseline-dm-burst-16.json diff --git a/benchmark/barback/results/baseline-dm-history-8.json b/benchmark/barback/testdata/results/baseline-dm-history-8.json similarity index 100% rename from benchmark/barback/results/baseline-dm-history-8.json rename to benchmark/barback/testdata/results/baseline-dm-history-8.json diff --git a/benchmark/barback/results/baseline-dm-parallel-32.json b/benchmark/barback/testdata/results/baseline-dm-parallel-32.json similarity index 100% rename from benchmark/barback/results/baseline-dm-parallel-32.json rename to benchmark/barback/testdata/results/baseline-dm-parallel-32.json diff --git a/benchmark/barback/results/baseline-dm-parallel-64.json b/benchmark/barback/testdata/results/baseline-dm-parallel-64.json similarity index 100% rename from benchmark/barback/results/baseline-dm-parallel-64.json rename to benchmark/barback/testdata/results/baseline-dm-parallel-64.json diff --git a/benchmark/barback/results/baseline-dm-parallel-8.json b/benchmark/barback/testdata/results/baseline-dm-parallel-8.json similarity index 100% rename from benchmark/barback/results/baseline-dm-parallel-8.json rename to benchmark/barback/testdata/results/baseline-dm-parallel-8.json diff --git a/benchmark/barback/results/baseline-dm-steady-1.json b/benchmark/barback/testdata/results/baseline-dm-steady-1.json similarity index 100% rename from benchmark/barback/results/baseline-dm-steady-1.json rename to benchmark/barback/testdata/results/baseline-dm-steady-1.json diff --git a/benchmark/barback/results/baseline-memory-dm-burst-16.heap.pb.gz b/benchmark/barback/testdata/results/baseline-memory-dm-burst-16.heap.pb.gz similarity index 100% rename from benchmark/barback/results/baseline-memory-dm-burst-16.heap.pb.gz rename to benchmark/barback/testdata/results/baseline-memory-dm-burst-16.heap.pb.gz diff --git a/benchmark/barback/results/baseline-memory-dm-burst-16.json b/benchmark/barback/testdata/results/baseline-memory-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/baseline-memory-dm-burst-16.json rename to benchmark/barback/testdata/results/baseline-memory-dm-burst-16.json diff --git a/benchmark/barback/results/baseline-memory-g128.heap.pb.gz b/benchmark/barback/testdata/results/baseline-memory-g128.heap.pb.gz similarity index 100% rename from benchmark/barback/results/baseline-memory-g128.heap.pb.gz rename to benchmark/barback/testdata/results/baseline-memory-g128.heap.pb.gz diff --git a/benchmark/barback/results/baseline-memory-g128.json b/benchmark/barback/testdata/results/baseline-memory-g128.json similarity index 100% rename from benchmark/barback/results/baseline-memory-g128.json rename to benchmark/barback/testdata/results/baseline-memory-g128.json diff --git a/benchmark/barback/results/baseline-secure-1-g128.json b/benchmark/barback/testdata/results/baseline-secure-1-g128.json similarity index 100% rename from benchmark/barback/results/baseline-secure-1-g128.json rename to benchmark/barback/testdata/results/baseline-secure-1-g128.json diff --git a/benchmark/barback/results/baseline-secure-2-g128.json b/benchmark/barback/testdata/results/baseline-secure-2-g128.json similarity index 100% rename from benchmark/barback/results/baseline-secure-2-g128.json rename to benchmark/barback/testdata/results/baseline-secure-2-g128.json diff --git a/benchmark/barback/results/candidate-dm-burst-16.json b/benchmark/barback/testdata/results/candidate-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/candidate-dm-burst-16.json rename to benchmark/barback/testdata/results/candidate-dm-burst-16.json diff --git a/benchmark/barback/results/candidate-dm-history-8.json b/benchmark/barback/testdata/results/candidate-dm-history-8.json similarity index 100% rename from benchmark/barback/results/candidate-dm-history-8.json rename to benchmark/barback/testdata/results/candidate-dm-history-8.json diff --git a/benchmark/barback/results/candidate-dm-parallel-32.json b/benchmark/barback/testdata/results/candidate-dm-parallel-32.json similarity index 100% rename from benchmark/barback/results/candidate-dm-parallel-32.json rename to benchmark/barback/testdata/results/candidate-dm-parallel-32.json diff --git a/benchmark/barback/results/candidate-dm-parallel-64.json b/benchmark/barback/testdata/results/candidate-dm-parallel-64.json similarity index 100% rename from benchmark/barback/results/candidate-dm-parallel-64.json rename to benchmark/barback/testdata/results/candidate-dm-parallel-64.json diff --git a/benchmark/barback/results/candidate-dm-parallel-8.json b/benchmark/barback/testdata/results/candidate-dm-parallel-8.json similarity index 100% rename from benchmark/barback/results/candidate-dm-parallel-8.json rename to benchmark/barback/testdata/results/candidate-dm-parallel-8.json diff --git a/benchmark/barback/results/candidate-dm-steady-1.json b/benchmark/barback/testdata/results/candidate-dm-steady-1.json similarity index 100% rename from benchmark/barback/results/candidate-dm-steady-1.json rename to benchmark/barback/testdata/results/candidate-dm-steady-1.json diff --git a/benchmark/barback/results/candidate-memory-dm-burst-16.heap.pb.gz b/benchmark/barback/testdata/results/candidate-memory-dm-burst-16.heap.pb.gz similarity index 100% rename from benchmark/barback/results/candidate-memory-dm-burst-16.heap.pb.gz rename to benchmark/barback/testdata/results/candidate-memory-dm-burst-16.heap.pb.gz diff --git a/benchmark/barback/results/candidate-memory-dm-burst-16.json b/benchmark/barback/testdata/results/candidate-memory-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/candidate-memory-dm-burst-16.json rename to benchmark/barback/testdata/results/candidate-memory-dm-burst-16.json diff --git a/benchmark/barback/results/candidate-memory-g128.heap.pb.gz b/benchmark/barback/testdata/results/candidate-memory-g128.heap.pb.gz similarity index 100% rename from benchmark/barback/results/candidate-memory-g128.heap.pb.gz rename to benchmark/barback/testdata/results/candidate-memory-g128.heap.pb.gz diff --git a/benchmark/barback/results/candidate-memory-g128.json b/benchmark/barback/testdata/results/candidate-memory-g128.json similarity index 100% rename from benchmark/barback/results/candidate-memory-g128.json rename to benchmark/barback/testdata/results/candidate-memory-g128.json diff --git a/benchmark/barback/results/candidate-secure-1-g128.json b/benchmark/barback/testdata/results/candidate-secure-1-g128.json similarity index 100% rename from benchmark/barback/results/candidate-secure-1-g128.json rename to benchmark/barback/testdata/results/candidate-secure-1-g128.json diff --git a/benchmark/barback/results/candidate-secure-2-g128.json b/benchmark/barback/testdata/results/candidate-secure-2-g128.json similarity index 100% rename from benchmark/barback/results/candidate-secure-2-g128.json rename to benchmark/barback/testdata/results/candidate-secure-2-g128.json diff --git a/benchmark/barback/results/dm-matrix.md b/benchmark/barback/testdata/results/dm-matrix.md similarity index 97% rename from benchmark/barback/results/dm-matrix.md rename to benchmark/barback/testdata/results/dm-matrix.md index e0e1c67c0..2860920fb 100644 --- a/benchmark/barback/results/dm-matrix.md +++ b/benchmark/barback/testdata/results/dm-matrix.md @@ -1,3 +1,8 @@ + + # Direct-message benchmark matrix On 2026-08-06, the baseline library at `61888a0` and the refined HyperMeow working tree were run against Barback `9e7d0dc` with the same PostgreSQL image, verified TLS and Noise certificates, and 2-vCPU/3.5-GB Compose limits. Every scenario started with a new database, device, and certificate set. diff --git a/benchmark/barback/results/hypermeow-client-memory-2000.json b/benchmark/barback/testdata/results/hypermeow-client-memory-2000.json similarity index 100% rename from benchmark/barback/results/hypermeow-client-memory-2000.json rename to benchmark/barback/testdata/results/hypermeow-client-memory-2000.json diff --git a/benchmark/barback/results/hypermeow-r1-dm-burst-16.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r1-dm-burst-16.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r1-dm-burst-16.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r1-dm-burst-16.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r1-dm-burst-16.json b/benchmark/barback/testdata/results/hypermeow-r1-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/hypermeow-r1-dm-burst-16.json rename to benchmark/barback/testdata/results/hypermeow-r1-dm-burst-16.json diff --git a/benchmark/barback/results/hypermeow-r1-dm-history-8.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r1-dm-history-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r1-dm-history-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r1-dm-history-8.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r1-dm-history-8.json b/benchmark/barback/testdata/results/hypermeow-r1-dm-history-8.json similarity index 100% rename from benchmark/barback/results/hypermeow-r1-dm-history-8.json rename to benchmark/barback/testdata/results/hypermeow-r1-dm-history-8.json diff --git a/benchmark/barback/results/hypermeow-r1-dm-mixed-8.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r1-dm-mixed-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r1-dm-mixed-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r1-dm-mixed-8.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r1-dm-mixed-8.json b/benchmark/barback/testdata/results/hypermeow-r1-dm-mixed-8.json similarity index 100% rename from benchmark/barback/results/hypermeow-r1-dm-mixed-8.json rename to benchmark/barback/testdata/results/hypermeow-r1-dm-mixed-8.json diff --git a/benchmark/barback/results/hypermeow-r1-group-128.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r1-group-128.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r1-group-128.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r1-group-128.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r1-group-128.json b/benchmark/barback/testdata/results/hypermeow-r1-group-128.json similarity index 100% rename from benchmark/barback/results/hypermeow-r1-group-128.json rename to benchmark/barback/testdata/results/hypermeow-r1-group-128.json diff --git a/benchmark/barback/results/hypermeow-r1-group-mixed-32.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r1-group-mixed-32.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r1-group-mixed-32.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r1-group-mixed-32.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r1-group-mixed-32.json b/benchmark/barback/testdata/results/hypermeow-r1-group-mixed-32.json similarity index 100% rename from benchmark/barback/results/hypermeow-r1-group-mixed-32.json rename to benchmark/barback/testdata/results/hypermeow-r1-group-mixed-32.json diff --git a/benchmark/barback/results/hypermeow-r2-dm-burst-16.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r2-dm-burst-16.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r2-dm-burst-16.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r2-dm-burst-16.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r2-dm-burst-16.json b/benchmark/barback/testdata/results/hypermeow-r2-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/hypermeow-r2-dm-burst-16.json rename to benchmark/barback/testdata/results/hypermeow-r2-dm-burst-16.json diff --git a/benchmark/barback/results/hypermeow-r2-dm-history-8.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r2-dm-history-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r2-dm-history-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r2-dm-history-8.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r2-dm-history-8.json b/benchmark/barback/testdata/results/hypermeow-r2-dm-history-8.json similarity index 100% rename from benchmark/barback/results/hypermeow-r2-dm-history-8.json rename to benchmark/barback/testdata/results/hypermeow-r2-dm-history-8.json diff --git a/benchmark/barback/results/hypermeow-r2-dm-mixed-8.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r2-dm-mixed-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r2-dm-mixed-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r2-dm-mixed-8.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r2-dm-mixed-8.json b/benchmark/barback/testdata/results/hypermeow-r2-dm-mixed-8.json similarity index 100% rename from benchmark/barback/results/hypermeow-r2-dm-mixed-8.json rename to benchmark/barback/testdata/results/hypermeow-r2-dm-mixed-8.json diff --git a/benchmark/barback/results/hypermeow-r2-group-128.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r2-group-128.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r2-group-128.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r2-group-128.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r2-group-128.json b/benchmark/barback/testdata/results/hypermeow-r2-group-128.json similarity index 100% rename from benchmark/barback/results/hypermeow-r2-group-128.json rename to benchmark/barback/testdata/results/hypermeow-r2-group-128.json diff --git a/benchmark/barback/results/hypermeow-r2-group-mixed-32.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r2-group-mixed-32.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r2-group-mixed-32.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r2-group-mixed-32.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r2-group-mixed-32.json b/benchmark/barback/testdata/results/hypermeow-r2-group-mixed-32.json similarity index 100% rename from benchmark/barback/results/hypermeow-r2-group-mixed-32.json rename to benchmark/barback/testdata/results/hypermeow-r2-group-mixed-32.json diff --git a/benchmark/barback/results/hypermeow-r3-dm-burst-16.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r3-dm-burst-16.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r3-dm-burst-16.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r3-dm-burst-16.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r3-dm-burst-16.json b/benchmark/barback/testdata/results/hypermeow-r3-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/hypermeow-r3-dm-burst-16.json rename to benchmark/barback/testdata/results/hypermeow-r3-dm-burst-16.json diff --git a/benchmark/barback/results/hypermeow-r3-dm-history-8.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r3-dm-history-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r3-dm-history-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r3-dm-history-8.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r3-dm-history-8.json b/benchmark/barback/testdata/results/hypermeow-r3-dm-history-8.json similarity index 100% rename from benchmark/barback/results/hypermeow-r3-dm-history-8.json rename to benchmark/barback/testdata/results/hypermeow-r3-dm-history-8.json diff --git a/benchmark/barback/results/hypermeow-r3-dm-mixed-8.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r3-dm-mixed-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r3-dm-mixed-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r3-dm-mixed-8.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r3-dm-mixed-8.json b/benchmark/barback/testdata/results/hypermeow-r3-dm-mixed-8.json similarity index 100% rename from benchmark/barback/results/hypermeow-r3-dm-mixed-8.json rename to benchmark/barback/testdata/results/hypermeow-r3-dm-mixed-8.json diff --git a/benchmark/barback/results/hypermeow-r3-group-128.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r3-group-128.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r3-group-128.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r3-group-128.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r3-group-128.json b/benchmark/barback/testdata/results/hypermeow-r3-group-128.json similarity index 100% rename from benchmark/barback/results/hypermeow-r3-group-128.json rename to benchmark/barback/testdata/results/hypermeow-r3-group-128.json diff --git a/benchmark/barback/results/hypermeow-r3-group-mixed-32.docker-stats.ndjson b/benchmark/barback/testdata/results/hypermeow-r3-group-mixed-32.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/hypermeow-r3-group-mixed-32.docker-stats.ndjson rename to benchmark/barback/testdata/results/hypermeow-r3-group-mixed-32.docker-stats.ndjson diff --git a/benchmark/barback/results/hypermeow-r3-group-mixed-32.json b/benchmark/barback/testdata/results/hypermeow-r3-group-mixed-32.json similarity index 100% rename from benchmark/barback/results/hypermeow-r3-group-mixed-32.json rename to benchmark/barback/testdata/results/hypermeow-r3-group-mixed-32.json diff --git a/benchmark/barback/results/maxrate-hypermeow-final-1700.json b/benchmark/barback/testdata/results/maxrate-hypermeow-final-1700.json similarity index 100% rename from benchmark/barback/results/maxrate-hypermeow-final-1700.json rename to benchmark/barback/testdata/results/maxrate-hypermeow-final-1700.json diff --git a/benchmark/barback/results/maxrate-hypermeow-final-1800.json b/benchmark/barback/testdata/results/maxrate-hypermeow-final-1800.json similarity index 100% rename from benchmark/barback/results/maxrate-hypermeow-final-1800.json rename to benchmark/barback/testdata/results/maxrate-hypermeow-final-1800.json diff --git a/benchmark/barback/results/maxrate-ping-pong.md b/benchmark/barback/testdata/results/maxrate-ping-pong.md similarity index 95% rename from benchmark/barback/results/maxrate-ping-pong.md rename to benchmark/barback/testdata/results/maxrate-ping-pong.md index 404549b50..097614375 100644 --- a/benchmark/barback/results/maxrate-ping-pong.md +++ b/benchmark/barback/testdata/results/maxrate-ping-pong.md @@ -1,3 +1,8 @@ + + # Ping-pong saturation comparison Measured on 2026-08-07 with the Compose stack capped at 2 vCPU and 3.5 GB of service memory. Each run used a fresh PostgreSQL volume and device, 64 DM senders, 64 chat-affine workers, a 3-second Signal-session warmup, and a 10-second text-message flood. TLS, Noise verification, Signal encryption/decryption, PostgreSQL persistence, server acknowledgements, and Barback pong decryption remained enabled. diff --git a/benchmark/barback/results/maxrate-whatsmeow-final-1000.json b/benchmark/barback/testdata/results/maxrate-whatsmeow-final-1000.json similarity index 100% rename from benchmark/barback/results/maxrate-whatsmeow-final-1000.json rename to benchmark/barback/testdata/results/maxrate-whatsmeow-final-1000.json diff --git a/benchmark/barback/results/maxrate-whatsmeow-final-900.json b/benchmark/barback/testdata/results/maxrate-whatsmeow-final-900.json similarity index 100% rename from benchmark/barback/results/maxrate-whatsmeow-final-900.json rename to benchmark/barback/testdata/results/maxrate-whatsmeow-final-900.json diff --git a/benchmark/barback/results/memory-g128.md b/benchmark/barback/testdata/results/memory-g128.md similarity index 96% rename from benchmark/barback/results/memory-g128.md rename to benchmark/barback/testdata/results/memory-g128.md index 2ab40becc..518407661 100644 --- a/benchmark/barback/results/memory-g128.md +++ b/benchmark/barback/testdata/results/memory-g128.md @@ -1,3 +1,8 @@ + + # Group-128 memory profile The unchanged baseline and HyperMeow ran the same secure workload with full-rate Go memory profiling: 128 additional group members, 200 messages, and four history-sync payloads containing 8,000 messages. Both runs completed without send failures or queue overflows. diff --git a/benchmark/barback/results/optimized-dm-burst-16.json b/benchmark/barback/testdata/results/optimized-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/optimized-dm-burst-16.json rename to benchmark/barback/testdata/results/optimized-dm-burst-16.json diff --git a/benchmark/barback/results/optimized-dm-history-8.json b/benchmark/barback/testdata/results/optimized-dm-history-8.json similarity index 100% rename from benchmark/barback/results/optimized-dm-history-8.json rename to benchmark/barback/testdata/results/optimized-dm-history-8.json diff --git a/benchmark/barback/results/optimized-dm-parallel-32.json b/benchmark/barback/testdata/results/optimized-dm-parallel-32.json similarity index 100% rename from benchmark/barback/results/optimized-dm-parallel-32.json rename to benchmark/barback/testdata/results/optimized-dm-parallel-32.json diff --git a/benchmark/barback/results/optimized-dm-parallel-64.json b/benchmark/barback/testdata/results/optimized-dm-parallel-64.json similarity index 100% rename from benchmark/barback/results/optimized-dm-parallel-64.json rename to benchmark/barback/testdata/results/optimized-dm-parallel-64.json diff --git a/benchmark/barback/results/optimized-dm-parallel-8.json b/benchmark/barback/testdata/results/optimized-dm-parallel-8.json similarity index 100% rename from benchmark/barback/results/optimized-dm-parallel-8.json rename to benchmark/barback/testdata/results/optimized-dm-parallel-8.json diff --git a/benchmark/barback/results/optimized-dm-steady-1.json b/benchmark/barback/testdata/results/optimized-dm-steady-1.json similarity index 100% rename from benchmark/barback/results/optimized-dm-steady-1.json rename to benchmark/barback/testdata/results/optimized-dm-steady-1.json diff --git a/benchmark/barback/results/optimized-memory-dm-burst-16.heap.pb.gz b/benchmark/barback/testdata/results/optimized-memory-dm-burst-16.heap.pb.gz similarity index 100% rename from benchmark/barback/results/optimized-memory-dm-burst-16.heap.pb.gz rename to benchmark/barback/testdata/results/optimized-memory-dm-burst-16.heap.pb.gz diff --git a/benchmark/barback/results/optimized-memory-dm-burst-16.json b/benchmark/barback/testdata/results/optimized-memory-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/optimized-memory-dm-burst-16.json rename to benchmark/barback/testdata/results/optimized-memory-dm-burst-16.json diff --git a/benchmark/barback/results/pre-pr3-client-memory-2000.json b/benchmark/barback/testdata/results/pre-pr3-client-memory-2000.json similarity index 100% rename from benchmark/barback/results/pre-pr3-client-memory-2000.json rename to benchmark/barback/testdata/results/pre-pr3-client-memory-2000.json diff --git a/benchmark/barback/results/pre-pr3-r1-dm-burst-16.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r1-dm-burst-16.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-dm-burst-16.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r1-dm-burst-16.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r1-dm-burst-16.json b/benchmark/barback/testdata/results/pre-pr3-r1-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-dm-burst-16.json rename to benchmark/barback/testdata/results/pre-pr3-r1-dm-burst-16.json diff --git a/benchmark/barback/results/pre-pr3-r1-dm-history-8.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r1-dm-history-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-dm-history-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r1-dm-history-8.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r1-dm-history-8.json b/benchmark/barback/testdata/results/pre-pr3-r1-dm-history-8.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-dm-history-8.json rename to benchmark/barback/testdata/results/pre-pr3-r1-dm-history-8.json diff --git a/benchmark/barback/results/pre-pr3-r1-dm-mixed-8.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r1-dm-mixed-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-dm-mixed-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r1-dm-mixed-8.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r1-dm-mixed-8.json b/benchmark/barback/testdata/results/pre-pr3-r1-dm-mixed-8.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-dm-mixed-8.json rename to benchmark/barback/testdata/results/pre-pr3-r1-dm-mixed-8.json diff --git a/benchmark/barback/results/pre-pr3-r1-group-128.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r1-group-128.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-group-128.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r1-group-128.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r1-group-128.json b/benchmark/barback/testdata/results/pre-pr3-r1-group-128.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-group-128.json rename to benchmark/barback/testdata/results/pre-pr3-r1-group-128.json diff --git a/benchmark/barback/results/pre-pr3-r1-group-mixed-32.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r1-group-mixed-32.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-group-mixed-32.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r1-group-mixed-32.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r1-group-mixed-32.json b/benchmark/barback/testdata/results/pre-pr3-r1-group-mixed-32.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r1-group-mixed-32.json rename to benchmark/barback/testdata/results/pre-pr3-r1-group-mixed-32.json diff --git a/benchmark/barback/results/pre-pr3-r2-dm-burst-16.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r2-dm-burst-16.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-dm-burst-16.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r2-dm-burst-16.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r2-dm-burst-16.json b/benchmark/barback/testdata/results/pre-pr3-r2-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-dm-burst-16.json rename to benchmark/barback/testdata/results/pre-pr3-r2-dm-burst-16.json diff --git a/benchmark/barback/results/pre-pr3-r2-dm-history-8.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r2-dm-history-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-dm-history-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r2-dm-history-8.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r2-dm-history-8.json b/benchmark/barback/testdata/results/pre-pr3-r2-dm-history-8.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-dm-history-8.json rename to benchmark/barback/testdata/results/pre-pr3-r2-dm-history-8.json diff --git a/benchmark/barback/results/pre-pr3-r2-dm-mixed-8.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r2-dm-mixed-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-dm-mixed-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r2-dm-mixed-8.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r2-dm-mixed-8.json b/benchmark/barback/testdata/results/pre-pr3-r2-dm-mixed-8.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-dm-mixed-8.json rename to benchmark/barback/testdata/results/pre-pr3-r2-dm-mixed-8.json diff --git a/benchmark/barback/results/pre-pr3-r2-group-128.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r2-group-128.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-group-128.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r2-group-128.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r2-group-128.json b/benchmark/barback/testdata/results/pre-pr3-r2-group-128.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-group-128.json rename to benchmark/barback/testdata/results/pre-pr3-r2-group-128.json diff --git a/benchmark/barback/results/pre-pr3-r2-group-mixed-32.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r2-group-mixed-32.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-group-mixed-32.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r2-group-mixed-32.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r2-group-mixed-32.json b/benchmark/barback/testdata/results/pre-pr3-r2-group-mixed-32.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r2-group-mixed-32.json rename to benchmark/barback/testdata/results/pre-pr3-r2-group-mixed-32.json diff --git a/benchmark/barback/results/pre-pr3-r3-dm-burst-16.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r3-dm-burst-16.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-dm-burst-16.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r3-dm-burst-16.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r3-dm-burst-16.json b/benchmark/barback/testdata/results/pre-pr3-r3-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-dm-burst-16.json rename to benchmark/barback/testdata/results/pre-pr3-r3-dm-burst-16.json diff --git a/benchmark/barback/results/pre-pr3-r3-dm-history-8.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r3-dm-history-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-dm-history-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r3-dm-history-8.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r3-dm-history-8.json b/benchmark/barback/testdata/results/pre-pr3-r3-dm-history-8.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-dm-history-8.json rename to benchmark/barback/testdata/results/pre-pr3-r3-dm-history-8.json diff --git a/benchmark/barback/results/pre-pr3-r3-dm-mixed-8.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r3-dm-mixed-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-dm-mixed-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r3-dm-mixed-8.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r3-dm-mixed-8.json b/benchmark/barback/testdata/results/pre-pr3-r3-dm-mixed-8.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-dm-mixed-8.json rename to benchmark/barback/testdata/results/pre-pr3-r3-dm-mixed-8.json diff --git a/benchmark/barback/results/pre-pr3-r3-group-128.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r3-group-128.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-group-128.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r3-group-128.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r3-group-128.json b/benchmark/barback/testdata/results/pre-pr3-r3-group-128.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-group-128.json rename to benchmark/barback/testdata/results/pre-pr3-r3-group-128.json diff --git a/benchmark/barback/results/pre-pr3-r3-group-mixed-32.docker-stats.ndjson b/benchmark/barback/testdata/results/pre-pr3-r3-group-mixed-32.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-group-mixed-32.docker-stats.ndjson rename to benchmark/barback/testdata/results/pre-pr3-r3-group-mixed-32.docker-stats.ndjson diff --git a/benchmark/barback/results/pre-pr3-r3-group-mixed-32.json b/benchmark/barback/testdata/results/pre-pr3-r3-group-mixed-32.json similarity index 100% rename from benchmark/barback/results/pre-pr3-r3-group-mixed-32.json rename to benchmark/barback/testdata/results/pre-pr3-r3-group-mixed-32.json diff --git a/benchmark/barback/results/ram-final-dm-burst-16.heap.pb.gz b/benchmark/barback/testdata/results/ram-final-dm-burst-16.heap.pb.gz similarity index 100% rename from benchmark/barback/results/ram-final-dm-burst-16.heap.pb.gz rename to benchmark/barback/testdata/results/ram-final-dm-burst-16.heap.pb.gz diff --git a/benchmark/barback/results/ram-final-dm-burst-16.json b/benchmark/barback/testdata/results/ram-final-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/ram-final-dm-burst-16.json rename to benchmark/barback/testdata/results/ram-final-dm-burst-16.json diff --git a/benchmark/barback/results/ram-final-dm-history-8.json b/benchmark/barback/testdata/results/ram-final-dm-history-8.json similarity index 100% rename from benchmark/barback/results/ram-final-dm-history-8.json rename to benchmark/barback/testdata/results/ram-final-dm-history-8.json diff --git a/benchmark/barback/results/ram-final-dm-parallel-64.json b/benchmark/barback/testdata/results/ram-final-dm-parallel-64.json similarity index 100% rename from benchmark/barback/results/ram-final-dm-parallel-64.json rename to benchmark/barback/testdata/results/ram-final-dm-parallel-64.json diff --git a/benchmark/barback/results/ram-final-group-128.json b/benchmark/barback/testdata/results/ram-final-group-128.json similarity index 100% rename from benchmark/barback/results/ram-final-group-128.json rename to benchmark/barback/testdata/results/ram-final-group-128.json diff --git a/benchmark/barback/results/ram-final-memory-g128.heap.pb.gz b/benchmark/barback/testdata/results/ram-final-memory-g128.heap.pb.gz similarity index 100% rename from benchmark/barback/results/ram-final-memory-g128.heap.pb.gz rename to benchmark/barback/testdata/results/ram-final-memory-g128.heap.pb.gz diff --git a/benchmark/barback/results/ram-final-memory-g128.json b/benchmark/barback/testdata/results/ram-final-memory-g128.json similarity index 100% rename from benchmark/barback/results/ram-final-memory-g128.json rename to benchmark/barback/testdata/results/ram-final-memory-g128.json diff --git a/benchmark/barback/results/ram-hardening.md b/benchmark/barback/testdata/results/ram-hardening.md similarity index 97% rename from benchmark/barback/results/ram-hardening.md rename to benchmark/barback/testdata/results/ram-hardening.md index fc239fb2f..d18ef2d93 100644 --- a/benchmark/barback/results/ram-hardening.md +++ b/benchmark/barback/testdata/results/ram-hardening.md @@ -1,3 +1,8 @@ + + # RAM hardening results On 2026-08-06, the PR #3 working tree was profiled again after the direct-message refinement. The comparison uses the same Barback revision, PostgreSQL image, verified TLS and Noise certificates, and 2-vCPU/3.5-GB Compose limits. Each run used a new PostgreSQL volume and paired device. diff --git a/benchmark/barback/results/system-comparison.md b/benchmark/barback/testdata/results/system-comparison.md similarity index 99% rename from benchmark/barback/results/system-comparison.md rename to benchmark/barback/testdata/results/system-comparison.md index 82dd35f81..b1f2a5e15 100644 --- a/benchmark/barback/results/system-comparison.md +++ b/benchmark/barback/testdata/results/system-comparison.md @@ -1,3 +1,8 @@ + + # HyperMeow system comparison On 2026-08-06, 45 clean Barback workloads compared upstream WhatsMeow, the state before PR #3, and the frozen HyperMeow candidate. Every revision completed three repetitions of five scenarios. All 45 runs reached their message target with zero send failures, zero queue overflows, and zero temporary files remaining. diff --git a/benchmark/barback/results/whatsmeow-client-memory-2000.json b/benchmark/barback/testdata/results/whatsmeow-client-memory-2000.json similarity index 100% rename from benchmark/barback/results/whatsmeow-client-memory-2000.json rename to benchmark/barback/testdata/results/whatsmeow-client-memory-2000.json diff --git a/benchmark/barback/results/whatsmeow-r1-dm-burst-16.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r1-dm-burst-16.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-dm-burst-16.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r1-dm-burst-16.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r1-dm-burst-16.json b/benchmark/barback/testdata/results/whatsmeow-r1-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-dm-burst-16.json rename to benchmark/barback/testdata/results/whatsmeow-r1-dm-burst-16.json diff --git a/benchmark/barback/results/whatsmeow-r1-dm-history-8.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r1-dm-history-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-dm-history-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r1-dm-history-8.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r1-dm-history-8.json b/benchmark/barback/testdata/results/whatsmeow-r1-dm-history-8.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-dm-history-8.json rename to benchmark/barback/testdata/results/whatsmeow-r1-dm-history-8.json diff --git a/benchmark/barback/results/whatsmeow-r1-dm-mixed-8.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r1-dm-mixed-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-dm-mixed-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r1-dm-mixed-8.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r1-dm-mixed-8.json b/benchmark/barback/testdata/results/whatsmeow-r1-dm-mixed-8.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-dm-mixed-8.json rename to benchmark/barback/testdata/results/whatsmeow-r1-dm-mixed-8.json diff --git a/benchmark/barback/results/whatsmeow-r1-group-128.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r1-group-128.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-group-128.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r1-group-128.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r1-group-128.json b/benchmark/barback/testdata/results/whatsmeow-r1-group-128.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-group-128.json rename to benchmark/barback/testdata/results/whatsmeow-r1-group-128.json diff --git a/benchmark/barback/results/whatsmeow-r1-group-mixed-32.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r1-group-mixed-32.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-group-mixed-32.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r1-group-mixed-32.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r1-group-mixed-32.json b/benchmark/barback/testdata/results/whatsmeow-r1-group-mixed-32.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r1-group-mixed-32.json rename to benchmark/barback/testdata/results/whatsmeow-r1-group-mixed-32.json diff --git a/benchmark/barback/results/whatsmeow-r2-dm-burst-16.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r2-dm-burst-16.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-dm-burst-16.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r2-dm-burst-16.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r2-dm-burst-16.json b/benchmark/barback/testdata/results/whatsmeow-r2-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-dm-burst-16.json rename to benchmark/barback/testdata/results/whatsmeow-r2-dm-burst-16.json diff --git a/benchmark/barback/results/whatsmeow-r2-dm-history-8.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r2-dm-history-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-dm-history-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r2-dm-history-8.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r2-dm-history-8.json b/benchmark/barback/testdata/results/whatsmeow-r2-dm-history-8.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-dm-history-8.json rename to benchmark/barback/testdata/results/whatsmeow-r2-dm-history-8.json diff --git a/benchmark/barback/results/whatsmeow-r2-dm-mixed-8.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r2-dm-mixed-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-dm-mixed-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r2-dm-mixed-8.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r2-dm-mixed-8.json b/benchmark/barback/testdata/results/whatsmeow-r2-dm-mixed-8.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-dm-mixed-8.json rename to benchmark/barback/testdata/results/whatsmeow-r2-dm-mixed-8.json diff --git a/benchmark/barback/results/whatsmeow-r2-group-128.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r2-group-128.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-group-128.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r2-group-128.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r2-group-128.json b/benchmark/barback/testdata/results/whatsmeow-r2-group-128.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-group-128.json rename to benchmark/barback/testdata/results/whatsmeow-r2-group-128.json diff --git a/benchmark/barback/results/whatsmeow-r2-group-mixed-32.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r2-group-mixed-32.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-group-mixed-32.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r2-group-mixed-32.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r2-group-mixed-32.json b/benchmark/barback/testdata/results/whatsmeow-r2-group-mixed-32.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r2-group-mixed-32.json rename to benchmark/barback/testdata/results/whatsmeow-r2-group-mixed-32.json diff --git a/benchmark/barback/results/whatsmeow-r3-dm-burst-16.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r3-dm-burst-16.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-dm-burst-16.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r3-dm-burst-16.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r3-dm-burst-16.json b/benchmark/barback/testdata/results/whatsmeow-r3-dm-burst-16.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-dm-burst-16.json rename to benchmark/barback/testdata/results/whatsmeow-r3-dm-burst-16.json diff --git a/benchmark/barback/results/whatsmeow-r3-dm-history-8.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r3-dm-history-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-dm-history-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r3-dm-history-8.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r3-dm-history-8.json b/benchmark/barback/testdata/results/whatsmeow-r3-dm-history-8.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-dm-history-8.json rename to benchmark/barback/testdata/results/whatsmeow-r3-dm-history-8.json diff --git a/benchmark/barback/results/whatsmeow-r3-dm-mixed-8.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r3-dm-mixed-8.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-dm-mixed-8.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r3-dm-mixed-8.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r3-dm-mixed-8.json b/benchmark/barback/testdata/results/whatsmeow-r3-dm-mixed-8.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-dm-mixed-8.json rename to benchmark/barback/testdata/results/whatsmeow-r3-dm-mixed-8.json diff --git a/benchmark/barback/results/whatsmeow-r3-group-128.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r3-group-128.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-group-128.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r3-group-128.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r3-group-128.json b/benchmark/barback/testdata/results/whatsmeow-r3-group-128.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-group-128.json rename to benchmark/barback/testdata/results/whatsmeow-r3-group-128.json diff --git a/benchmark/barback/results/whatsmeow-r3-group-mixed-32.docker-stats.ndjson b/benchmark/barback/testdata/results/whatsmeow-r3-group-mixed-32.docker-stats.ndjson similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-group-mixed-32.docker-stats.ndjson rename to benchmark/barback/testdata/results/whatsmeow-r3-group-mixed-32.docker-stats.ndjson diff --git a/benchmark/barback/results/whatsmeow-r3-group-mixed-32.json b/benchmark/barback/testdata/results/whatsmeow-r3-group-mixed-32.json similarity index 100% rename from benchmark/barback/results/whatsmeow-r3-group-mixed-32.json rename to benchmark/barback/testdata/results/whatsmeow-r3-group-mixed-32.json diff --git a/binary/decoder_test.go b/binary/decoder_test.go index 8377e374c..558a95ab4 100644 --- a/binary/decoder_test.go +++ b/binary/decoder_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package binary_test import ( diff --git a/binary/xml_test.go b/binary/xml_test.go index e8e63504c..9def53ea4 100644 --- a/binary/xml_test.go +++ b/binary/xml_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package binary import ( diff --git a/business.go b/business.go index ce8b18e72..da3f059b8 100644 --- a/business.go +++ b/business.go @@ -7,12 +7,30 @@ package whatsmeow import ( + "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" "fmt" + "io" + "net/http" + "net/mail" + "net/url" "strconv" "strings" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "google.golang.org/protobuf/proto" waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/mex" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/socket" "github.com/polymorfa/hypermeow/types" ) @@ -180,3 +198,2802 @@ func parseIntChild(node waBinary.Node, tag string) (int, error) { } return value, nil } + +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) +} + +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() || jid.User == "" { + 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"` + 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 { + 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{} + } + 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 +} + +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 +} + +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 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 catalog response: %w", err) + } + raw, ok := envelope[discriminator] + 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"` + } + 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 decodeBusinessCatalogSuccess(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 decodeBusinessCatalogSuccess(data, "xfb_whatsapp_catalog_update_collection_list") +} + +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") +} + +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.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 + 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") +} + +type BusinessProductMessageParams struct { + BusinessOwnerJID types.JID + ProductID string + Title string + Description string + CurrencyCode string + PriceAmount1000 int64 + SalePriceAmount1000 int64 + SalePricePresent bool + RetailerID string + URL string + ProductImageCount uint32 + ProductImage *waE2E.ImageMessage + Body string + Footer string + ContextInfo *waE2E.ContextInfo +} + +type BusinessProductSection struct { + Title string + ProductIDs []string +} + +type BusinessProductListMessageParams struct { + BusinessOwnerJID types.JID + Title string + Description string + ButtonText string + Footer string + Sections []BusinessProductSection + ContextInfo *waE2E.ContextInfo +} + +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 + ContextInfo *waE2E.ContextInfo +} + +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 + ContextInfo *waE2E.ContextInfo +} + +type BusinessNativeFlowButton struct { + Name string + ParamsJSON string +} + +type BusinessNativeFlowButtonsMessageParams struct { + Title string + Body string + Footer string + Buttons []BusinessNativeFlowButton + 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) +} + +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, 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 || params.SalePricePresent) { + 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 { + 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") + } + } + priceAmount1000 := optionalPositiveInt64(params.PriceAmount1000) + 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: 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, + }}, 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, 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 { + 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, 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) { + 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)} + } + sections[index] = &waE2E.ListMessage_ProductSection{Title: optionalString(section.Title), Products: 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.ToNonAD().String())}, ContextInfo: params.ContextInfo, + }}, 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.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) { + 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.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 !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 { + 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, 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) { + 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)} + } + sections[sectionIndex] = &waE2E.ListMessage_Section{Title: optionalString(section.Title), Rows: 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, + }}, nil +} + +func BuildBusinessNativeFlowButtonsMessage(params BusinessNativeFlowButtonsMessageParams) (*waE2E.Message, error) { + 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 { + 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(), ContextInfo: params.ContextInfo, + } + 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 +} + +func BuildBusinessAddressMessage(params BusinessAddressMessageParams) (*waE2E.Message, error) { + 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 { + 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, 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) == "" || !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 !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 !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 + if params.DataJSON != "" { + 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"` + } + 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} +} + +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") +) + +const businessNonceDeliveredAttr = "__whatsmeow_business_nonce_delivered" + +type businessAccessToken struct { + accessToken string + actorID string +} + +type businessNonceWaiter struct { + ch chan string +} + +type businessCatalogAuthState struct { + tokenLock chan struct{} + 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 + } + 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 + } + } + 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 !isUppercaseCurrency(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 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") + } + 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{tokenLock: make(chan struct{}, 1)} + created.tokenLock <- struct{}{} + 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 (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 { + 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() + select { + case <-state.tokenLock: + defer func() { state.tokenLock <- struct{}{} }() + case <-ctx.Done(): + return businessAccessToken{}, ctx.Err() + } + 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(ctx context.Context, token string) error { + state := cli.businessCatalogAuth.Load() + if state == nil { + return nil + } + select { + case <-state.tokenLock: + case <-ctx.Done(): + return ctx.Err() + } + 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) { + 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"` + } + 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 { + 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")) { + return nil, fmt.Errorf("business GraphQL response is missing data") + } + return envelope.Data, nil +} + +func businessCatalogMutationVariablesWithActor(variables map[string]any, actorID string) (map[string]any, error) { + if strings.TrimSpace(actorID) == "" { + 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 catalog 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) 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 := businessCatalogMutationVariablesWithActor(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 + } + if attempt == 0 && isBusinessGraphQLAuthError(err) { + if err = cli.invalidateBusinessAccessToken(ctx, token.accessToken); err != nil { + return nil, err + } + continue + } + return nil, err + } + return nil, fmt.Errorf("business catalog 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.executeBusinessCatalogMutation(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.executeBusinessCatalogMutation(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.executeBusinessCatalogMutation(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 { + 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)) + 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 { + if urlErr, ok := err.(*url.Error); ok { + err = urlErr.Err + } + 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 +} + +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) > 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 { + 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 buildBusinessProfileMutationNode(children...), nil +} + +func buildBusinessProfileMutationNode(children ...waBinary.Node) waBinary.Node { + return waBinary.Node{ + Tag: "business_profile", + Attrs: waBinary.Attrs{ + "v": "3", + "mutation_type": "delta", + }, + Content: children, + } +} + +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) > 7 { + return waBinary.Node{}, fmt.Errorf("business hours must contain at most 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 { + 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) + 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{buildBusinessProfileMutationNode(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{buildBusinessProfileMutationNode(node)}, + }) + if err != nil { + return fmt.Errorf("failed to delete business cover photo: %w", err) + } + return nil +} diff --git a/business_account.go b/business_account.go deleted file mode 100644 index a841311db..000000000 --- a/business_account.go +++ /dev/null @@ -1,343 +0,0 @@ -package whatsmeow - -import ( - "context" - "fmt" - "strconv" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/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 deleted file mode 100644 index ec77a62a4..000000000 --- a/business_account_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package whatsmeow - -import ( - "strings" - "testing" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/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/business_catalog.go b/business_catalog.go deleted file mode 100644 index 8b031299a..000000000 --- a/business_catalog.go +++ /dev/null @@ -1,427 +0,0 @@ -package whatsmeow - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/polymorfa/hypermeow/mex" - "github.com/polymorfa/hypermeow/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() || jid.User == "" { - 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"` - 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 { - 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{} - } - 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 -} - -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 deleted file mode 100644 index d0087b4aa..000000000 --- a/business_catalog_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package whatsmeow - -import ( - "encoding/json" - "strings" - "testing" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/types" -) - -func TestBuildCatalogVariablesRejectsInvalidInput(t *testing.T) { - tests := []struct { - name string - jid types.JID - 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}}, - {"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":[]},"paging":{"after":"next","before":"previous"}}}`) - collection, err := decodeSingleCollection(raw) - if err != nil || collection.ID != "c-summer" || collection.Next != "next" || collection.Previous != "previous" { - 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 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{}) - 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/business_collection_mutation.go b/business_collection_mutation.go deleted file mode 100644 index c5cb16533..000000000 --- a/business_collection_mutation.go +++ /dev/null @@ -1,292 +0,0 @@ -package whatsmeow - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/google/uuid" - - "github.com/polymorfa/hypermeow/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 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 catalog response: %w", err) - } - raw, ok := envelope[discriminator] - 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"` - } - 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 decodeBusinessCatalogSuccess(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 decodeBusinessCatalogSuccess(data, "xfb_whatsapp_catalog_update_collection_list") -} diff --git a/business_collection_mutation_test.go b/business_collection_mutation_test.go deleted file mode 100644 index 3de02d5de..000000000 --- a/business_collection_mutation_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package whatsmeow - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/polymorfa/hypermeow/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 = 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) - } - } -} diff --git a/business_commerce_control.go b/business_commerce_control.go deleted file mode 100644 index 832e8a5fa..000000000 --- a/business_commerce_control.go +++ /dev/null @@ -1,192 +0,0 @@ -package whatsmeow - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/polymorfa/hypermeow/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 deleted file mode 100644 index c8dfc4fda..000000000 --- a/business_commerce_control_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package whatsmeow - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/polymorfa/hypermeow/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_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 := 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) - } - 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") - } -} diff --git a/business_merchant_compliance.go b/business_merchant_compliance.go deleted file mode 100644 index e93de3594..000000000 --- a/business_merchant_compliance.go +++ /dev/null @@ -1,163 +0,0 @@ -package whatsmeow - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/polymorfa/hypermeow/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.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 - 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 deleted file mode 100644 index eef660783..000000000 --- a/business_merchant_compliance_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package whatsmeow - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "reflect" - "strings" - "testing" - - "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) - -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: "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) }}, - {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", actorID: "synthetic-actor"} - 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: - 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":{}}}}}` - 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/business_message_builders.go b/business_message_builders.go deleted file mode 100644 index 723b99342..000000000 --- a/business_message_builders.go +++ /dev/null @@ -1,418 +0,0 @@ -package whatsmeow - -import ( - "encoding/json" - "errors" - "fmt" - "net/url" - "strings" - "unicode/utf8" - - "google.golang.org/protobuf/proto" - - "github.com/polymorfa/hypermeow/proto/waE2E" - "github.com/polymorfa/hypermeow/types" -) - -type BusinessProductMessageParams struct { - BusinessOwnerJID types.JID - ProductID string - Title string - Description string - CurrencyCode string - PriceAmount1000 int64 - SalePriceAmount1000 int64 - SalePricePresent bool - RetailerID string - URL string - ProductImageCount uint32 - ProductImage *waE2E.ImageMessage - Body string - Footer string - ContextInfo *waE2E.ContextInfo -} - -type BusinessProductSection struct { - Title string - ProductIDs []string -} - -type BusinessProductListMessageParams struct { - BusinessOwnerJID types.JID - Title string - Description string - ButtonText string - Footer string - Sections []BusinessProductSection - ContextInfo *waE2E.ContextInfo -} - -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 - ContextInfo *waE2E.ContextInfo -} - -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 - ContextInfo *waE2E.ContextInfo -} - -type BusinessNativeFlowButton struct { - Name string - ParamsJSON string -} - -type BusinessNativeFlowButtonsMessageParams struct { - Title string - Body string - Footer string - Buttons []BusinessNativeFlowButton - 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) -} - -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, 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 || params.SalePricePresent) { - 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 { - 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") - } - } - priceAmount1000 := optionalPositiveInt64(params.PriceAmount1000) - 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: 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, - }}, 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, 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 { - 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, 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) { - 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)} - } - sections[index] = &waE2E.ListMessage_ProductSection{Title: optionalString(section.Title), Products: 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.ToNonAD().String())}, ContextInfo: params.ContextInfo, - }}, 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.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) { - 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.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 !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 { - 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, 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) { - 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)} - } - sections[sectionIndex] = &waE2E.ListMessage_Section{Title: optionalString(section.Title), Rows: 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, - }}, nil -} - -func BuildBusinessNativeFlowButtonsMessage(params BusinessNativeFlowButtonsMessageParams) (*waE2E.Message, error) { - 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 { - 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(), ContextInfo: params.ContextInfo, - } - 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 -} - -func BuildBusinessAddressMessage(params BusinessAddressMessageParams) (*waE2E.Message, error) { - 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 { - 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, 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) == "" || !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 !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 !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 - if params.DataJSON != "" { - 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"` - } - 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 deleted file mode 100644 index 3398d4336..000000000 --- a/business_message_builders_test.go +++ /dev/null @@ -1,553 +0,0 @@ -package whatsmeow - -import ( - "encoding/json" - "fmt" - "strings" - "testing" - - "github.com/polymorfa/hypermeow/proto/waE2E" - "github.com/polymorfa/hypermeow/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", - 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" || len(product.GetContextInfo().GetMentionedJID()) != 1 { - 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 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 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), - 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 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", - 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 TestBusinessMessageBuildersNormalizeOwnerJIDs(t *testing.T) { - deviceOwner := types.NewADJID("15550001", 0, 3) - product, err := BuildBusinessProductMessage(BusinessProductMessageParams{ - BusinessOwnerJID: deviceOwner, ProductID: "p-tea", Title: "Tea", CurrencyCode: "USD", PriceAmount1000: 1250, - }) - 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", Description: "Choose a product", 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 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 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 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}, - "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) { - if _, err := BuildBusinessAddressMessage(params); err == nil { - t.Fatal("expected address text limit error") - } - }) - } -} - -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, - }, - "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, - }, - } - 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 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", - FlowAction: "navigate", Screen: "APPOINTMENT", DataJSON: `{"location":"beirut","order_id":9007199254740993}`, - }) - 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) - } - 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 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") - } - 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", 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 { - t.Fatal("expected product validation error") - } - }) - } - 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"}}}, - }); 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 := 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 { - 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") - } - 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) { - 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){ - "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") - } - }) - } - 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", - 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", - 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 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) - }, - } - 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") - } - }) - } - 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 } diff --git a/business_product_mutation.go b/business_product_mutation.go deleted file mode 100644 index 1b25b1f96..000000000 --- a/business_product_mutation.go +++ /dev/null @@ -1,773 +0,0 @@ -package whatsmeow - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "sync/atomic" - "time" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/socket" - "github.com/polymorfa/hypermeow/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") -) - -const businessNonceDeliveredAttr = "__whatsmeow_business_nonce_delivered" - -type businessAccessToken struct { - accessToken string - actorID string -} - -type businessNonceWaiter struct { - ch chan string -} - -type businessCatalogAuthState struct { - tokenLock chan struct{} - 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 - } - 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 - } - } - 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 !isUppercaseCurrency(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 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") - } - 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{tokenLock: make(chan struct{}, 1)} - created.tokenLock <- struct{}{} - 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 (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 { - 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() - select { - case <-state.tokenLock: - defer func() { state.tokenLock <- struct{}{} }() - case <-ctx.Done(): - return businessAccessToken{}, ctx.Err() - } - 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(ctx context.Context, token string) error { - state := cli.businessCatalogAuth.Load() - if state == nil { - return nil - } - select { - case <-state.tokenLock: - case <-ctx.Done(): - return ctx.Err() - } - 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) { - 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"` - } - 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 { - 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")) { - return nil, fmt.Errorf("business GraphQL response is missing data") - } - return envelope.Data, nil -} - -func businessCatalogMutationVariablesWithActor(variables map[string]any, actorID string) (map[string]any, error) { - if strings.TrimSpace(actorID) == "" { - 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 catalog 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) 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 := businessCatalogMutationVariablesWithActor(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 - } - if attempt == 0 && isBusinessGraphQLAuthError(err) { - if err = cli.invalidateBusinessAccessToken(ctx, token.accessToken); err != nil { - return nil, err - } - continue - } - return nil, err - } - return nil, fmt.Errorf("business catalog 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.executeBusinessCatalogMutation(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.executeBusinessCatalogMutation(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.executeBusinessCatalogMutation(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 { - 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)) - 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 { - if urlErr, ok := err.(*url.Error); ok { - err = urlErr.Err - } - 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 deleted file mode 100644 index 572133405..000000000 --- a/business_product_mutation_test.go +++ /dev/null @@ -1,436 +0,0 @@ -package whatsmeow - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "slices" - "strings" - "testing" - "time" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/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: "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"}}, - } - 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 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 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 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 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 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.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"}) { - 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" { - 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 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) - 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) - } -} - -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) - } -} - -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) - } -} diff --git a/business_profile.go b/business_profile.go deleted file mode 100644 index 2f2fdd593..000000000 --- a/business_profile.go +++ /dev/null @@ -1,302 +0,0 @@ -package whatsmeow - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/mail" - "net/url" - "strconv" - "strings" - "time" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/socket" - "github.com/polymorfa/hypermeow/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) > 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 { - 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 buildBusinessProfileMutationNode(children...), nil -} - -func buildBusinessProfileMutationNode(children ...waBinary.Node) waBinary.Node { - return waBinary.Node{ - Tag: "business_profile", - Attrs: waBinary.Attrs{ - "v": "3", - "mutation_type": "delta", - }, - Content: children, - } -} - -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) > 7 { - return waBinary.Node{}, fmt.Errorf("business hours must contain at most 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 { - 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) - 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{buildBusinessProfileMutationNode(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{buildBusinessProfileMutationNode(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 deleted file mode 100644 index 0c0dcf508..000000000 --- a/business_profile_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package whatsmeow - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/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 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 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{ - {}, - {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) - } -} - -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") - } - 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) - } - 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) - } - 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") - } -} diff --git a/business_test.go b/business_test.go new file mode 100644 index 000000000..39a2662d2 --- /dev/null +++ b/business_test.go @@ -0,0 +1,1859 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "slices" + "strings" + "testing" + "time" + + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + waLog "github.com/polymorfa/hypermeow/util/log" +) + +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") + } +} + +func TestBuildCatalogVariablesRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + jid types.JID + 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}}, + {"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":[]},"paging":{"after":"next","before":"previous"}}}`) + collection, err := decodeSingleCollection(raw) + if err != nil || collection.ID != "c-summer" || collection.Next != "next" || collection.Previous != "previous" { + 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 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{}) + 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) + } +} + +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 = 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) + } + } +} + +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_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 := 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) + } + 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") + } +} + +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: "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) }}, + {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", actorID: "synthetic-actor"} + 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: + 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":{}}}}}` + 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) + } +} + +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", + 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" || len(product.GetContextInfo().GetMentionedJID()) != 1 { + 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 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 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), + 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 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", + 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 TestBusinessMessageBuildersNormalizeOwnerJIDs(t *testing.T) { + deviceOwner := types.NewADJID("15550001", 0, 3) + product, err := BuildBusinessProductMessage(BusinessProductMessageParams{ + BusinessOwnerJID: deviceOwner, ProductID: "p-tea", Title: "Tea", CurrencyCode: "USD", PriceAmount1000: 1250, + }) + 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", Description: "Choose a product", 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 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 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 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}, + "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) { + if _, err := BuildBusinessAddressMessage(params); err == nil { + t.Fatal("expected address text limit error") + } + }) + } +} + +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, + }, + "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, + }, + } + 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 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", + FlowAction: "navigate", Screen: "APPOINTMENT", DataJSON: `{"location":"beirut","order_id":9007199254740993}`, + }) + 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) + } + 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 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") + } + 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", 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 { + t.Fatal("expected product validation error") + } + }) + } + 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"}}}, + }); 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 := 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 { + 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") + } + 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) { + 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){ + "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") + } + }) + } + 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", + 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", + 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 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) + }, + } + 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") + } + }) + } + 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 } + +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: "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"}}, + } + 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 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 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 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 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 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.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"}) { + 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" { + 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 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) + 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) + } +} + +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) + } +} + +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) + } +} + +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 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 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{ + {}, + {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) + } +} + +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") + } + 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) + } + 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) + } + 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") + } +} diff --git a/cache.go b/cache.go index df4b9e21e..ac470c070 100644 --- a/cache.go +++ b/cache.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package whatsmeow import "time" diff --git a/cache_test.go b/cache_test.go deleted file mode 100644 index 21c04e55b..000000000 --- a/cache_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package whatsmeow - -import ( - "fmt" - "testing" - "time" -) - -func TestPutBoundedCache(t *testing.T) { - cache := make(map[string]int, 2) - putBoundedCache(cache, "one", 1, 2) - putBoundedCache(cache, "two", 2, 2) - putBoundedCache(cache, "three", 3, 2) - - if len(cache) != 2 { - t.Fatalf("cache grew to %d entries", len(cache)) - } - if cache["three"] != 3 { - t.Fatal("new value was not cached") - } - putBoundedCache(cache, "three", 4, 2) - if len(cache) != 2 || cache["three"] != 4 { - t.Fatal("updating a value changed the cache size") - } -} - -func TestPruneExpiredCache(t *testing.T) { - now := time.Now() - cache := map[string]time.Time{ - "old": now.Add(-2 * time.Hour), - "new": now, - } - pruneExpiredCache(cache, now.Add(-time.Hour)) - - if _, ok := cache["old"]; ok { - t.Fatal("expired entry was retained") - } - if _, ok := cache["new"]; !ok { - t.Fatal("fresh entry was removed") - } -} - -func TestClientCachesStayBounded(t *testing.T) { - messageRetries := make(map[string]int, maxMessageRetryEntries) - for i := 0; i <= maxMessageRetryEntries; i++ { - key := fmt.Sprintf("message-%d", i) - putBoundedCache(messageRetries, key, i, maxMessageRetryEntries) - } - if len(messageRetries) != maxMessageRetryEntries { - t.Fatalf("message retry cache grew to %d entries", len(messageRetries)) - } -} - -func TestIncrementBoundedCounterFailsClosedAndResets(t *testing.T) { - now := time.Now() - resetAt := now - cache := map[string]int{"one": 1} - if _, accepted := incrementBoundedCounter(cache, "two", 1, &resetAt, now); accepted { - t.Fatal("accepted a new counter after reaching capacity") - } - count, accepted := incrementBoundedCounter(cache, "two", 1, &resetAt, now.Add(retryCounterWindow)) - if !accepted || count != 1 { - t.Fatalf("counter did not reset: accepted=%t count=%d", accepted, count) - } - if _, exists := cache["one"]; exists { - t.Fatal("old counter survived reset") - } -} diff --git a/client_memory_test.go b/client_runtime_test.go similarity index 54% rename from client_memory_test.go rename to client_runtime_test.go index 9261c460b..ddfee6a1f 100644 --- a/client_memory_test.go +++ b/client_runtime_test.go @@ -1,16 +1,84 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package whatsmeow import ( "context" + "fmt" "net/http" "runtime" + "sync" "testing" + "time" waBinary "github.com/polymorfa/hypermeow/binary" "github.com/polymorfa/hypermeow/store" waLog "github.com/polymorfa/hypermeow/util/log" ) +func TestPutBoundedCache(t *testing.T) { + cache := make(map[string]int, 2) + putBoundedCache(cache, "one", 1, 2) + putBoundedCache(cache, "two", 2, 2) + putBoundedCache(cache, "three", 3, 2) + + if len(cache) != 2 { + t.Fatalf("cache grew to %d entries", len(cache)) + } + if cache["three"] != 3 { + t.Fatal("new value was not cached") + } + putBoundedCache(cache, "three", 4, 2) + if len(cache) != 2 || cache["three"] != 4 { + t.Fatal("updating a value changed the cache size") + } +} + +func TestPruneExpiredCache(t *testing.T) { + now := time.Now() + cache := map[string]time.Time{ + "old": now.Add(-2 * time.Hour), + "new": now, + } + pruneExpiredCache(cache, now.Add(-time.Hour)) + + if _, ok := cache["old"]; ok { + t.Fatal("expired entry was retained") + } + if _, ok := cache["new"]; !ok { + t.Fatal("fresh entry was removed") + } +} + +func TestClientCachesStayBounded(t *testing.T) { + messageRetries := make(map[string]int, maxMessageRetryEntries) + for i := 0; i <= maxMessageRetryEntries; i++ { + key := fmt.Sprintf("message-%d", i) + putBoundedCache(messageRetries, key, i, maxMessageRetryEntries) + } + if len(messageRetries) != maxMessageRetryEntries { + t.Fatalf("message retry cache grew to %d entries", len(messageRetries)) + } +} + +func TestIncrementBoundedCounterFailsClosedAndResets(t *testing.T) { + now := time.Now() + resetAt := now + cache := map[string]int{"one": 1} + if _, accepted := incrementBoundedCounter(cache, "two", 1, &resetAt, now); accepted { + t.Fatal("accepted a new counter after reaching capacity") + } + count, accepted := incrementBoundedCounter(cache, "two", 1, &resetAt, now.Add(retryCounterWindow)) + if !accepted || count != 1 { + t.Fatalf("counter did not reset: accepted=%t count=%d", accepted, count) + } + if _, exists := cache["one"]; exists { + t.Fatal("old counter survived reset") + } +} + func TestNewClientDefersSparseState(t *testing.T) { client := NewClient(&store.Device{}, waLog.Noop) if client.messageRetries != nil || client.incomingRetryRequestCounter != nil || client.appStateKeyRequests != nil { @@ -82,3 +150,23 @@ func BenchmarkNewClient(b *testing.B) { } runtime.KeepAlive(clients) } + +func TestRefreshMediaConnConcurrentCacheReads(t *testing.T) { + cached := &MediaConn{TTL: 60, FetchedAt: time.Now(), Hosts: []MediaConnHost{{Hostname: "media.example.test"}}} + client := &Client{mediaConnCache: cached} + + var wait sync.WaitGroup + for range 32 { + wait.Add(1) + go func() { + defer wait.Done() + got, err := client.refreshMediaConn(context.Background(), false) + if err != nil { + t.Errorf("refreshMediaConn failed: %v", err) + } else if got != cached { + t.Errorf("refreshMediaConn returned %p, want %p", got, cached) + } + }() + } + wait.Wait() +} diff --git a/errors_iq_test.go b/errors_iq_test.go deleted file mode 100644 index 4cbc77d07..000000000 --- a/errors_iq_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package whatsmeow - -import ( - "errors" - "testing" - - waBinary "github.com/polymorfa/hypermeow/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") - } -} - -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") - } -} - -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") - } -} - -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") - } - }) - } -} - -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") - } - }) - } -} diff --git a/go.mod b/go.mod index 6478839ec..7529430a8 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,11 @@ go 1.25.0 toolchain go1.26.5 +retract ( + v0.1.1 // Retraction carrier only; use @main or v0.0.0. + v0.1.0 // Published accidentally; use @main or v0.0.0. +) + require ( github.com/beeper/argo-go v1.1.2 github.com/coder/websocket v1.8.15 diff --git a/group_username_test.go b/group_username_test.go deleted file mode 100644 index d83be9b18..000000000 --- a/group_username_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package whatsmeow - -import ( - "testing" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/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/http.go b/http.go index d0f67d835..be37239b7 100644 --- a/http.go +++ b/http.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package whatsmeow import "io" diff --git a/identity_test.go b/identity_test.go new file mode 100644 index 000000000..afc4e2dd0 --- /dev/null +++ b/identity_test.go @@ -0,0 +1,938 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "slices" + "sync" + "testing" + "time" + + "google.golang.org/protobuf/proto" + + "github.com/polymorfa/hypermeow/appstate" + waBinary "github.com/polymorfa/hypermeow/binary" + "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waFingerprint" + "github.com/polymorfa/hypermeow/proto/waHistorySync" + "github.com/polymorfa/hypermeow/proto/waSyncAction" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" + "github.com/polymorfa/hypermeow/types/events" + waLog "github.com/polymorfa/hypermeow/util/log" +) + +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) + } +} + +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.NewADJID("15550001111", types.WhatsAppDomain, 7) + lid := types.NewJID("100000011111111", types.HiddenUserServer) + 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) + } + want := lid + want.Device = pn.Device + if resolved != want { + t.Fatalf("resolved LID = %s, want %s", resolved, want) + } +} + +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") + } +} + +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 +} + +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) + } +} + +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") + } + }) + } +} + +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) + } +} + +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 +} + +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 } +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, uint64, 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 { + result[address] = key + } + return result, irs.generation, nil + } + for _, address := range addresses { + if key, ok := irs.keys[address]; ok { + result[address] = key + } + } + return result, irs.generation, nil +} +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 + } + 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) + 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) { + 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 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, true) + 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 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 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), + 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) + } +} + +func TestFilterContactsPreservesUsername(t *testing.T) { + client := &Client{} + _, 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" || 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 { + 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) + 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"), + }}, + }, false) + 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) + } +} + +type singleUsernameStore struct { + store.NoopStore + entries []store.ContactUsernameEntry +} + +type failingUsernameStore struct { + store.NoopStore + 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") +} + +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 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} + 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{ + {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) + } +} + +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) + } +} + +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]) + } +} + +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) + } +} + +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]) + } +} + +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 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", + 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) + } +} diff --git a/internal/cmd/genmex/main.go b/internal/cmd/genmex/main.go index 27f6ba238..093651b1e 100644 --- a/internal/cmd/genmex/main.go +++ b/internal/cmd/genmex/main.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package main import ( @@ -91,7 +95,10 @@ func render(parsed spec) ([]byte, error) { sort.Strings(names) var out strings.Builder - out.WriteString("// Code generated by internal/cmd/genmex; DO NOT EDIT.\n\n") + out.WriteString("// Copyright (c) 2026 Rajeh Taher\n" + + "//\n" + + "// Licensed under the MIT License. See LICENSE-MIT for details.\n\n" + + "// 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") diff --git a/lid_resolution_test.go b/lid_resolution_test.go deleted file mode 100644 index 59b7d2508..000000000 --- a/lid_resolution_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package whatsmeow - -import ( - "context" - "testing" - - "github.com/polymorfa/hypermeow/store" - "github.com/polymorfa/hypermeow/types" - waLog "github.com/polymorfa/hypermeow/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.NewADJID("15550001111", types.WhatsAppDomain, 7) - lid := types.NewJID("100000011111111", types.HiddenUserServer) - 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) - } - want := lid - want.Device = pn.Device - if resolved != want { - t.Fatalf("resolved LID = %s, want %s", resolved, want) - } -} - -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/mediaconn_test.go b/mediaconn_test.go deleted file mode 100644 index 623206445..000000000 --- a/mediaconn_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package whatsmeow - -import ( - "context" - "sync" - "testing" - "time" -) - -func TestRefreshMediaConnConcurrentCacheReads(t *testing.T) { - cached := &MediaConn{TTL: 60, FetchedAt: time.Now(), Hosts: []MediaConnHost{{Hostname: "media.example.test"}}} - client := &Client{mediaConnCache: cached} - - var wait sync.WaitGroup - for range 32 { - wait.Add(1) - go func() { - defer wait.Done() - got, err := client.refreshMediaConn(context.Background(), false) - if err != nil { - t.Errorf("refreshMediaConn failed: %v", err) - } else if got != cached { - t.Errorf("refreshMediaConn returned %p, want %p", got, cached) - } - }() - } - wait.Wait() -} diff --git a/message_name_updates_test.go b/message_name_updates_test.go deleted file mode 100644 index bb66faf3e..000000000 --- a/message_name_updates_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package whatsmeow - -import ( - "context" - "testing" - "time" - - "github.com/polymorfa/hypermeow/store" - "github.com/polymorfa/hypermeow/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 -} diff --git a/messaging_test.go b/messaging_test.go new file mode 100644 index 000000000..d30929603 --- /dev/null +++ b/messaging_test.go @@ -0,0 +1,403 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + +package whatsmeow + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "testing" + "time" + + "google.golang.org/protobuf/proto" + + waBinary "github.com/polymorfa/hypermeow/binary" + waE2E "github.com/polymorfa/hypermeow/proto/waE2E" + "github.com/polymorfa/hypermeow/proto/waWa6" + "github.com/polymorfa/hypermeow/store" + "github.com/polymorfa/hypermeow/types" +) + +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") + } +} + +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") + } +} + +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") + } +} + +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") + } + }) + } +} + +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") + } + }) + } +} + +func TestBuildDeleteNewsletterVariablesRejectsNonNewsletterJID(t *testing.T) { + tests := []types.JID{ + 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 { + 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) + } +} + +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) + } +} + +func TestMarkReadRejectsMultipleReceiptTypes(t *testing.T) { + client := &Client{} + err := client.MarkRead( + context.Background(), + []types.MessageID{"message"}, + time.Now(), + types.NewJID("123", types.DefaultUserServer), + types.EmptyJID, + types.ReceiptTypePlayed, + types.ReceiptTypeRead, + ) + if err == nil || !strings.Contains(err.Error(), "too many receipt types") { + t.Fatalf("MarkRead error = %v", err) + } +} + +func TestRecentMessageCacheStoresSerializedMessage(t *testing.T) { + cli := &Client{} + to := types.NewJID("123", types.DefaultUserServer) + message := &waE2E.Message{Conversation: proto.String("original")} + if err := cli.addRecentMessage(context.Background(), to, "message", message, nil); err != nil { + t.Fatalf("failed to cache message: %v", err) + } + message.Conversation = proto.String("mutated") + + cached := cli.getRecentMessage(to, "message") + if cached.wa.GetConversation() != "original" { + t.Fatalf("cached message changed to %q", cached.wa.GetConversation()) + } + if len(cli.recentMessagesMap[recentMessageKey{To: to, ID: "message"}].payload) == 0 { + t.Fatal("serialized payload is empty") + } +} + +func TestRecentMessageCacheGrowsOnDemand(t *testing.T) { + cli := &Client{} + to := types.NewJID("123", types.DefaultUserServer) + message := &waE2E.Message{Conversation: proto.String("first")} + if err := cli.addRecentMessage(context.Background(), to, "message", message, nil); err != nil { + t.Fatalf("failed to cache message: %v", err) + } + if len(cli.recentMessagesList) != 1 { + t.Fatalf("recent message ring length = %d, want 1", len(cli.recentMessagesList)) + } +} + +func TestRecentMessageCacheDoesNotDuplicateKeys(t *testing.T) { + cli := &Client{} + to := types.NewJID("123", types.DefaultUserServer) + for _, text := range []string{"first", "updated"} { + message := &waE2E.Message{Conversation: proto.String(text)} + if err := cli.addRecentMessage(context.Background(), to, "message", message, nil); err != nil { + t.Fatalf("failed to cache message: %v", err) + } + } + if len(cli.recentMessagesList) != 1 { + t.Fatalf("recent message ring length = %d, want 1", len(cli.recentMessagesList)) + } + if got := cli.getRecentMessage(to, "message").wa.GetConversation(); got != "updated" { + t.Fatalf("cached message = %q, want updated", got) + } +} + +func TestRecentMessageCacheEvictsOldest(t *testing.T) { + cli := &Client{} + to := types.NewJID("123", types.DefaultUserServer) + for i := 0; i <= recentMessagesSize; i++ { + id := fmt.Sprintf("message-%d", i) + message := &waE2E.Message{Conversation: proto.String(id)} + if err := cli.addRecentMessage(context.Background(), to, id, message, nil); err != nil { + t.Fatalf("failed to cache message: %v", err) + } + } + + if !cli.getRecentMessage(to, "message-0").IsEmpty() { + t.Fatal("oldest message was retained") + } + if cli.getRecentMessage(to, "message-1").IsEmpty() { + t.Fatal("second-oldest message was evicted") + } + if cli.getRecentMessage(to, fmt.Sprintf("message-%d", recentMessagesSize)).IsEmpty() { + t.Fatal("newest message was not cached") + } +} + +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) + } + }) + } +} + +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 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{ + 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}}, + "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" { + t.Fatalf("native-flow name = %q", flow.Attrs["name"]) + } + }) + } +} + +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 + 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) + } + }) + } +} + +type failingReadSeeker struct{} + +func (failingReadSeeker) Read([]byte) (int, error) { + return 0, errors.New("fixture read failure") +} + +func (failingReadSeeker) Seek(int64, int) (int64, error) { + return 0, nil +} + +func TestUploadNewsletterReaderReturnsHashingError(t *testing.T) { + client := &Client{} + _, err := client.UploadNewsletterReader(context.Background(), failingReadSeeker{}, MediaImage) + if err == nil || !strings.Contains(err.Error(), "failed to hash newsletter upload") { + t.Fatalf("UploadNewsletterReader error = %v", err) + } +} + +var _ io.ReadSeeker = failingReadSeeker{} diff --git a/mex/bindings.go b/mex/bindings.go index a89bdef0e..a34a7623c 100644 --- a/mex/bindings.go +++ b/mex/bindings.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + // Code generated by internal/cmd/genmex; DO NOT EDIT. package mex diff --git a/mex/bindings_test.go b/mex/bindings_test.go index 04ef94f48..1ef1abf4a 100644 --- a/mex/bindings_test.go +++ b/mex/bindings_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package mex import "testing" diff --git a/mex/generate.go b/mex/generate.go index 6d4f332d4..44883a7fd 100644 --- a/mex/generate.go +++ b/mex/generate.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package mex //go:generate go run ../internal/cmd/genmex -input spec.json -output bindings.go diff --git a/newsletter_delete.go b/newsletter_delete.go index bfcca4342..9778ecfe3 100644 --- a/newsletter_delete.go +++ b/newsletter_delete.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package whatsmeow import ( diff --git a/newsletter_delete_test.go b/newsletter_delete_test.go deleted file mode 100644 index fa78af925..000000000 --- a/newsletter_delete_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package whatsmeow - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/polymorfa/hypermeow/proto/waWa6" - "github.com/polymorfa/hypermeow/store" - "github.com/polymorfa/hypermeow/types" -) - -func TestBuildDeleteNewsletterVariablesRejectsNonNewsletterJID(t *testing.T) { - tests := []types.JID{ - 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 { - 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) - } -} - -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) - } -} diff --git a/notification_device_test.go b/notification_device_test.go deleted file mode 100644 index d8bf1ac00..000000000 --- a/notification_device_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package whatsmeow - -import ( - "context" - "testing" - - 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) { - 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) - } -} - -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/notification_identity_test.go b/notification_identity_test.go deleted file mode 100644 index 8046f66da..000000000 --- a/notification_identity_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package whatsmeow - -import ( - "context" - "slices" - "testing" - - 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 { - 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) - } -} diff --git a/phone_number_message.go b/phone_number_message.go index 1db9add66..4302bc05d 100644 --- a/phone_number_message.go +++ b/phone_number_message.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package whatsmeow import "github.com/polymorfa/hypermeow/proto/waE2E" diff --git a/phone_number_message_test.go b/phone_number_message_test.go deleted file mode 100644 index bf75ce73f..000000000 --- a/phone_number_message_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package whatsmeow - -import ( - "testing" - - "github.com/polymorfa/hypermeow/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 -} diff --git a/privacysettings_test.go b/privacysettings_test.go deleted file mode 100644 index 61fe68961..000000000 --- a/privacysettings_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package whatsmeow - -import ( - "testing" - - "github.com/polymorfa/hypermeow/types" -) - -func TestApplyPrivacySettingUpdatesEveryCategory(t *testing.T) { - tests := []struct { - name types.PrivacySettingType - value types.PrivacySetting - get func(types.PrivacySettings) types.PrivacySetting - }{ - {types.PrivacySettingTypeGroupAdd, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.GroupAdd }}, - {types.PrivacySettingTypeLastSeen, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.LastSeen }}, - {types.PrivacySettingTypeStatus, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.Status }}, - {types.PrivacySettingTypeProfile, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.Profile }}, - {types.PrivacySettingTypeReadReceipts, types.PrivacySettingNone, func(s types.PrivacySettings) types.PrivacySetting { return s.ReadReceipts }}, - {types.PrivacySettingTypeOnline, types.PrivacySettingMatchLastSeen, func(s types.PrivacySettings) types.PrivacySetting { return s.Online }}, - {types.PrivacySettingTypeCallAdd, types.PrivacySettingKnown, func(s types.PrivacySettings) types.PrivacySetting { return s.CallAdd }}, - {types.PrivacySettingTypeMessages, types.PrivacySettingContacts, func(s types.PrivacySettings) types.PrivacySetting { return s.Messages }}, - {types.PrivacySettingTypeDefense, types.PrivacySettingOnStandard, func(s types.PrivacySettings) types.PrivacySetting { return s.Defense }}, - {types.PrivacySettingTypeStickers, types.PrivacySettingContactAllowlist, func(s types.PrivacySettings) types.PrivacySetting { return s.Stickers }}, - } - - for _, test := range tests { - t.Run(string(test.name), func(t *testing.T) { - var settings types.PrivacySettings - applyPrivacySetting(&settings, test.name, test.value) - if actual := test.get(settings); actual != test.value { - t.Fatalf("setting = %q, want %q", actual, test.value) - } - }) - } -} diff --git a/receipt_test.go b/receipt_test.go deleted file mode 100644 index 95e75686d..000000000 --- a/receipt_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package whatsmeow - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/polymorfa/hypermeow/types" -) - -func TestMarkReadRejectsMultipleReceiptTypes(t *testing.T) { - client := &Client{} - err := client.MarkRead( - context.Background(), - []types.MessageID{"message"}, - time.Now(), - types.NewJID("123", types.DefaultUserServer), - types.EmptyJID, - types.ReceiptTypePlayed, - types.ReceiptTypeRead, - ) - if err == nil || !strings.Contains(err.Error(), "too many receipt types") { - t.Fatalf("MarkRead error = %v", err) - } -} diff --git a/retry_test.go b/retry_test.go deleted file mode 100644 index 220cf55f5..000000000 --- a/retry_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package whatsmeow - -import ( - "context" - "fmt" - "testing" - - "google.golang.org/protobuf/proto" - - "github.com/polymorfa/hypermeow/proto/waE2E" - "github.com/polymorfa/hypermeow/types" -) - -func TestRecentMessageCacheStoresSerializedMessage(t *testing.T) { - cli := &Client{} - to := types.NewJID("123", types.DefaultUserServer) - message := &waE2E.Message{Conversation: proto.String("original")} - if err := cli.addRecentMessage(context.Background(), to, "message", message, nil); err != nil { - t.Fatalf("failed to cache message: %v", err) - } - message.Conversation = proto.String("mutated") - - cached := cli.getRecentMessage(to, "message") - if cached.wa.GetConversation() != "original" { - t.Fatalf("cached message changed to %q", cached.wa.GetConversation()) - } - if len(cli.recentMessagesMap[recentMessageKey{To: to, ID: "message"}].payload) == 0 { - t.Fatal("serialized payload is empty") - } -} - -func TestRecentMessageCacheGrowsOnDemand(t *testing.T) { - cli := &Client{} - to := types.NewJID("123", types.DefaultUserServer) - message := &waE2E.Message{Conversation: proto.String("first")} - if err := cli.addRecentMessage(context.Background(), to, "message", message, nil); err != nil { - t.Fatalf("failed to cache message: %v", err) - } - if len(cli.recentMessagesList) != 1 { - t.Fatalf("recent message ring length = %d, want 1", len(cli.recentMessagesList)) - } -} - -func TestRecentMessageCacheDoesNotDuplicateKeys(t *testing.T) { - cli := &Client{} - to := types.NewJID("123", types.DefaultUserServer) - for _, text := range []string{"first", "updated"} { - message := &waE2E.Message{Conversation: proto.String(text)} - if err := cli.addRecentMessage(context.Background(), to, "message", message, nil); err != nil { - t.Fatalf("failed to cache message: %v", err) - } - } - if len(cli.recentMessagesList) != 1 { - t.Fatalf("recent message ring length = %d, want 1", len(cli.recentMessagesList)) - } - if got := cli.getRecentMessage(to, "message").wa.GetConversation(); got != "updated" { - t.Fatalf("cached message = %q, want updated", got) - } -} - -func TestRecentMessageCacheEvictsOldest(t *testing.T) { - cli := &Client{} - to := types.NewJID("123", types.DefaultUserServer) - for i := 0; i <= recentMessagesSize; i++ { - id := fmt.Sprintf("message-%d", i) - message := &waE2E.Message{Conversation: proto.String(id)} - if err := cli.addRecentMessage(context.Background(), to, id, message, nil); err != nil { - t.Fatalf("failed to cache message: %v", err) - } - } - - if !cli.getRecentMessage(to, "message-0").IsEmpty() { - t.Fatal("oldest message was retained") - } - if cli.getRecentMessage(to, "message-1").IsEmpty() { - t.Fatal("second-oldest message was evicted") - } - if cli.getRecentMessage(to, fmt.Sprintf("message-%d", recentMessagesSize)).IsEmpty() { - t.Fatal("newest message was not cached") - } -} diff --git a/security_code.go b/security_code.go index 6464c7c8e..34f723080 100644 --- a/security_code.go +++ b/security_code.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package whatsmeow import ( diff --git a/security_code_test.go b/security_code_test.go deleted file mode 100644 index 91b2aa338..000000000 --- a/security_code_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package whatsmeow - -import ( - "bytes" - "context" - "encoding/hex" - "errors" - "slices" - "sync" - "testing" - - "google.golang.org/protobuf/proto" - - "github.com/polymorfa/hypermeow/proto/waFingerprint" - "github.com/polymorfa/hypermeow/store" - "github.com/polymorfa/hypermeow/types" -) - -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 } -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, uint64, 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 { - result[address] = key - } - return result, irs.generation, nil - } - for _, address := range addresses { - if key, ok := irs.keys[address]; ok { - result[address] = key - } - } - return result, irs.generation, nil -} -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 - } - 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) - 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) { - 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 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, true) - 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 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 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), - 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/send_test.go b/send_test.go deleted file mode 100644 index a03bc5af9..000000000 --- a/send_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package whatsmeow - -import ( - "testing" - - "google.golang.org/protobuf/proto" - - waBinary "github.com/polymorfa/hypermeow/binary" - waE2E "github.com/polymorfa/hypermeow/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) - } - }) - } -} - -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 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{ - 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}}, - "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" { - t.Fatalf("native-flow name = %q", flow.Attrs["name"]) - } - }) - } -} - -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 - 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/store/clientpayload_test.go b/store/clientpayload_test.go index 3ad0c0c1e..580b0a1ba 100644 --- a/store/clientpayload_test.go +++ b/store/clientpayload_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package store import "testing" diff --git a/store/contact_test.go b/store/contact_test.go index e631ad373..bc9c18b87 100644 --- a/store/contact_test.go +++ b/store/contact_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package store import ( diff --git a/store/sessioncache_test.go b/store/sessioncache_test.go index 0bced960a..d80a7e27d 100644 --- a/store/sessioncache_test.go +++ b/store/sessioncache_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package store import ( diff --git a/store/sqlstore/identity_reader_test.go b/store/sqlstore/identity_reader_test.go index 87e73bf2d..33ac50e5c 100644 --- a/store/sqlstore/identity_reader_test.go +++ b/store/sqlstore/identity_reader_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package sqlstore import ( diff --git a/store/sqlstore/lidmap_test.go b/store/sqlstore/lidmap_test.go index 1303b2a3b..cf3968487 100644 --- a/store/sqlstore/lidmap_test.go +++ b/store/sqlstore/lidmap_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package sqlstore import ( diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 746cd297c..2fcddd1e2 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package sqlstore import ( diff --git a/store/sqlstore/upgrades/16-sender-key-migration-index.sql b/store/sqlstore/upgrades/16-sender-key-migration-index.sql index fb3cfb9c9..4eca873f8 100644 --- a/store/sqlstore/upgrades/16-sender-key-migration-index.sql +++ b/store/sqlstore/upgrades/16-sender-key-migration-index.sql @@ -1,2 +1,6 @@ -- v16 (compatible with v8+): Index PN-addressed sender keys for LID migration checks +-- Copyright (c) 2026 Rajeh Taher +-- +-- Licensed under the MIT License. See LICENSE-MIT for details. + CREATE INDEX whatsmeow_sender_keys_sender_idx ON whatsmeow_sender_keys (our_jid, sender_id); diff --git a/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql b/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql index 8a1575eeb..765753166 100644 --- a/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql +++ b/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql @@ -1,4 +1,8 @@ -- v17 (compatible with v8+): Index PN migration prefix lookups on PostgreSQL +-- Copyright (c) 2026 Rajeh Taher +-- +-- Licensed under the MIT License. See LICENSE-MIT for details. + -- only: postgres CREATE INDEX whatsmeow_identity_keys_their_pattern_idx ON whatsmeow_identity_keys (our_jid, their_id text_pattern_ops); -- only: postgres diff --git a/store/sqlstore/upgrades/18-contact-username.sql b/store/sqlstore/upgrades/18-contact-username.sql index 6d31cec7f..8a759b4a3 100644 --- a/store/sqlstore/upgrades/18-contact-username.sql +++ b/store/sqlstore/upgrades/18-contact-username.sql @@ -1,2 +1,6 @@ -- v18 (compatible with v8+): Persist the optional WhatsApp username alongside the LID-keyed contact. +-- Copyright (c) 2026 Rajeh Taher +-- +-- Licensed under the MIT License. See LICENSE-MIT for details. + 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 e1bbe4e79..79eb18cb5 100644 --- a/store/sqlstore/upgrades/upgrades_test.go +++ b/store/sqlstore/upgrades/upgrades_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package upgrades import ( @@ -158,7 +162,7 @@ func TestUpgradeFromCurrentDevSchemaAddsUsername(t *testing.T) { } want := "ALTER TABLE whatsmeow_contacts ADD COLUMN username TEXT;" for _, query := range state.executed { - if strings.TrimSpace(query) == want { + if strings.Contains(query, want) { return } } diff --git a/store/store_test.go b/store/store_test.go index 4ed5d3227..c7301cae1 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package store import ( diff --git a/types/business_account.go b/types/business_account.go index e57743856..293a01805 100644 --- a/types/business_account.go +++ b/types/business_account.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package types type BusinessLinkedAccounts struct { diff --git a/types/business_catalog.go b/types/business_catalog.go index 4ef73196d..877c5eca9 100644 --- a/types/business_catalog.go +++ b/types/business_catalog.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package types type BusinessCatalogPage struct { diff --git a/upload_test.go b/upload_test.go deleted file mode 100644 index afca896b0..000000000 --- a/upload_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package whatsmeow - -import ( - "context" - "errors" - "io" - "strings" - "testing" -) - -type failingReadSeeker struct{} - -func (failingReadSeeker) Read([]byte) (int, error) { - return 0, errors.New("fixture read failure") -} - -func (failingReadSeeker) Seek(int64, int) (int64, error) { - return 0, nil -} - -func TestUploadNewsletterReaderReturnsHashingError(t *testing.T) { - client := &Client{} - _, err := client.UploadNewsletterReader(context.Background(), failingReadSeeker{}, MediaImage) - if err == nil || !strings.Contains(err.Error(), "failed to hash newsletter upload") { - t.Fatalf("UploadNewsletterReader error = %v", err) - } -} - -var _ io.ReadSeeker = failingReadSeeker{} diff --git a/username_contact_test.go b/username_contact_test.go deleted file mode 100644 index 388375be2..000000000 --- a/username_contact_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package whatsmeow - -import ( - "context" - "testing" - - "google.golang.org/protobuf/proto" - - "github.com/polymorfa/hypermeow/appstate" - "github.com/polymorfa/hypermeow/proto/waSyncAction" - "github.com/polymorfa/hypermeow/store" - "github.com/polymorfa/hypermeow/types" - "github.com/polymorfa/hypermeow/types/events" -) - -func TestFilterContactsPreservesUsername(t *testing.T) { - client := &Client{} - _, 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" || 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 { - 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) - 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"), - }}, - }, false) - 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) - } -} diff --git a/username_persistence_test.go b/username_persistence_test.go deleted file mode 100644 index 09c2153a9..000000000 --- a/username_persistence_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package whatsmeow - -import ( - "context" - "errors" - "testing" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/store" - "github.com/polymorfa/hypermeow/types" - waLog "github.com/polymorfa/hypermeow/util/log" -) - -type singleUsernameStore struct { - store.NoopStore - entries []store.ContactUsernameEntry -} - -type failingUsernameStore struct { - store.NoopStore - 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") -} - -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 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} - 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{ - {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) - } -} - -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) - } -} - -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]) - } -} - -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) - } -} - -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]) - } -} diff --git a/username_resolution_test.go b/username_resolution_test.go deleted file mode 100644 index 1c164c95e..000000000 --- a/username_resolution_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package whatsmeow - -import ( - "testing" - - waBinary "github.com/polymorfa/hypermeow/binary" - "github.com/polymorfa/hypermeow/proto/waHistorySync" - "github.com/polymorfa/hypermeow/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 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", - 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) - } -} diff --git a/util/cbcutil/cbc_test.go b/util/cbcutil/cbc_test.go index 851771591..7c8534236 100644 --- a/util/cbcutil/cbc_test.go +++ b/util/cbcutil/cbc_test.go @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Rajeh Taher +// +// Licensed under the MIT License. See LICENSE-MIT for details. + package cbcutil import (