diff --git a/.gitattributes b/.gitattributes
index f8256663f..39b0e4990 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,4 @@
*.pb.go linguist-generated=true
*.pb.raw binary linguist-generated=true
internals.go linguist-generated=true
+*.patch -whitespace
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index aa8188501..00eeae11d 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -22,6 +22,9 @@ jobs:
with:
go-version: ${{ matrix.go-version }}
+ - name: Check generated MEX bindings
+ run: go run ./internal/cmd/genmex -input mex/spec.json -output mex/bindings.go -check
+
- name: Build
run: go build -v ./...
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bec963f3c..aa389af8a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,10 +4,11 @@ repos:
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
- exclude: LICENSE
+ exclude: '(^LICENSE$|\.patch$)'
- id: end-of-file-fixer
exclude: LICENSE
- id: check-yaml
+ exclude: ^benchmark/barback/compose\.(dm|local-cache)\.yaml$
- id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang
@@ -16,7 +17,7 @@ repos:
- id: go-imports-repo
args:
- "-local"
- - "go.mau.fi/whatsmeow"
+ - "github.com/polymorfa/hypermeow"
- "-w"
- id: go-vet-repo-mod
# TODO enable this
diff --git a/README.md b/README.md
index e89c8559e..ee8f58606 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,56 @@
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 its own Go module, imported directly as `github.com/polymorfa/hypermeow`. It no longer requires a `replace` directive:
+
+```go
+import whatsmeow "github.com/polymorfa/hypermeow"
+```
+
+```sh
+go get github.com/polymorfa/hypermeow
+```
+
+### The module is `hypermeow`, the package is `whatsmeow`
+
+Only the *module path* moved. The Go *package* names are unchanged from upstream, so the root package is still declared `package whatsmeow`. Both of these are expected:
+
+- godoc renders the title as **"whatsmeow package - github.com/polymorfa/hypermeow"**;
+- the import path's last element (`hypermeow`) does not match the package name (`whatsmeow`), so import the root package under an explicit alias as shown above.
+
+This is deliberate. Keeping the upstream package names means no call site changes when migrating - `whatsmeow.Client`, `whatsmeow.NewClient` and friends all still resolve - and merges from tulir's upstream do not conflict on the package clause of every file. Sub-packages are unaffected, since their path element already matches their package name (`.../store` is `package store`, and so on).
+
+### Only one whatsmeow may be linked into a binary
+
+HyperMeow keeps upstream's generated protobuf descriptor paths (`waCommon/WACommon.proto` and friends) and their symbol namespaces. Those are registered in a process-global registry, so linking HyperMeow **and** upstream `go.mau.fi/whatsmeow` into the same binary panics before `main`:
+
+```
+proto: file "waCommon/WACommon.proto" is already registered
+```
+
+The old `replace` arrangement made this impossible, because both import paths resolved to one module. A distinct module path removes that guarantee, so a partially migrated dependency graph — where one of your dependencies still requires `go.mau.fi/whatsmeow` — is not safe.
+
+The failure is loud and immediate rather than silent, but it surfaces at process start. Check for it at build time instead:
+
+```sh
+go mod why -m go.mau.fi/whatsmeow # should report the module is not needed
+```
+
+Use `-m`. Without it, `go mod why` asks about the *package* `go.mau.fi/whatsmeow`, and a dependency that imports only a subpackage — say `go.mau.fi/whatsmeow/proto/waCommon` — makes it answer "main module does not need package" while the upstream module is linked and its descriptors still collide.
+
+A stricter check inspects the link graph directly:
+
+```sh
+deps="$(go list -deps -test ./...)" || exit 1
+case "$deps" in *go.mau.fi/whatsmeow*) exit 1 ;; esac
+```
+
+Exit 0 means safe. There is deliberately no pipeline and no external command here. `-test` matters because `go list -deps` omits test-only dependencies, and a project importing upstream only from a `_test.go` still links both copies under `go test`. Capturing the output first means a failed `go list` propagates through `||` instead of being mistaken for an empty result, and matching with `case` avoids `grep`, whose exit status is 0 on a match — so a naive form of this check passes precisely when the graph is unsafe.
+
+or assert it in a test via `debug.ReadBuildInfo()`, failing if any entry in `Deps` reports the module path `go.mau.fi/whatsmeow`.
+
+Migrating from the previous `replace go.mau.fi/whatsmeow => github.com/polymorfa/hypermeow` setup: drop the `replace` line, add a normal `require` on `github.com/polymorfa/hypermeow`, and rewrite `go.mau.fi/whatsmeow` import paths to `github.com/polymorfa/hypermeow`. A `replace` directive is only honoured in the main module, so the previous arrangement did not carry to anything that depended on your module in turn; a direct requirement does.
+
The reproducible Barback and PostgreSQL benchmark is documented in [`benchmark/barback`](benchmark/barback/README.md).
## Discussion
diff --git a/appstate.go b/appstate.go
index e46a651ad..6e870c331 100644
--- a/appstate.go
+++ b/appstate.go
@@ -17,13 +17,13 @@ import (
"go.mau.fi/util/exslices"
"go.mau.fi/util/ptr"
- "go.mau.fi/whatsmeow/appstate"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/proto/waServerSync"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ "github.com/polymorfa/hypermeow/appstate"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/proto/waServerSync"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
// FetchAppState fetches updates to the given type of app state. If fullSync is true, the current
@@ -67,7 +67,7 @@ func (cli *Client) fetchAppState(ctx context.Context, name appstate.WAPatchName,
wantSnapshot := fullSync
var eventsToDispatch []any
eventsToDispatchPtr := &eventsToDispatch
- if fullSync && !cli.EmitAppStateEventsOnFullSync {
+ if fullSync && !cli.EmitAppStateEventsOnFullSync && !cli.EmitLabelEventsOnFullSync && !cli.EmitQuickReplyEventsOnFullSync {
eventsToDispatchPtr = nil
}
for hasMore {
@@ -108,7 +108,7 @@ func (cli *Client) handleAppStateRecovery(
}
var eventsToDispatch []any
eventsToDispatchPtr := &eventsToDispatch
- if !cli.EmitAppStateEventsOnFullSync {
+ if !cli.EmitAppStateEventsOnFullSync && !cli.EmitLabelEventsOnFullSync && !cli.EmitQuickReplyEventsOnFullSync {
eventsToDispatchPtr = nil
}
snapshot, err := appstate.ParseRecovery(result[0].GetSyncdSnapshotFatalRecoveryResponse())
@@ -186,28 +186,62 @@ func (cli *Client) collectEventsToDispatch(
}
}
for _, mutation := range mutations {
- if eventsToDispatch != nil && mutation.Operation == waServerSync.SyncdMutation_SET {
+ emitMutation := eventsToDispatch != nil && (!fullSync || cli.shouldEmitFullSyncMutation(mutation.Index))
+ if emitMutation && mutation.Operation == waServerSync.SyncdMutation_SET {
*eventsToDispatch = append(*eventsToDispatch, &events.AppState{Index: mutation.Index, SyncActionValue: mutation.Action})
}
evt := cli.dispatchAppState(ctx, name, mutation, fullSync)
- if eventsToDispatch != nil && evt != nil {
+ if emitMutation && evt != nil {
*eventsToDispatch = append(*eventsToDispatch, evt)
}
}
return nil
}
+func (cli *Client) shouldEmitFullSyncMutation(index []string) bool {
+ if cli.EmitAppStateEventsOnFullSync {
+ return true
+ }
+ if len(index) == 0 {
+ return false
+ }
+ if cli.EmitQuickReplyEventsOnFullSync && index[0] == appstate.IndexQuickReply {
+ return true
+ }
+ if !cli.EmitLabelEventsOnFullSync {
+ return false
+ }
+ switch index[0] {
+ case appstate.IndexLabelEdit, appstate.IndexLabelAssociationChat, appstate.IndexLabelAssociationMessage:
+ return true
+ default:
+ return false
+ }
+}
+
func (cli *Client) filterContacts(mutations []appstate.Mutation) ([]appstate.Mutation, []store.ContactEntry) {
filteredMutations := mutations[:0]
contacts := make([]store.ContactEntry, 0, len(mutations))
for _, mutation := range mutations {
- if mutation.Index[0] == "contact" && len(mutation.Index) > 1 {
+ if len(mutation.Index) > 1 && mutation.Index[0] == appstate.IndexContact {
jid, _ := types.ParseJID(mutation.Index[1])
act := mutation.Action.GetContactAction()
contacts = append(contacts, store.ContactEntry{
- JID: jid,
- FirstName: act.GetFirstName(),
- FullName: act.GetFullName(),
+ JID: jid,
+ FirstName: act.GetFirstName(),
+ FullName: act.GetFullName(),
+ Username: act.GetUsername(),
+ UsernameSet: true,
+ })
+ } else if len(mutation.Index) > 1 && mutation.Index[0] == appstate.IndexLIDContact {
+ jid, _ := types.ParseJID(mutation.Index[1])
+ act := mutation.Action.GetLidContactAction()
+ contacts = append(contacts, store.ContactEntry{
+ JID: jid,
+ FirstName: act.GetFirstName(),
+ FullName: act.GetFullName(),
+ Username: act.GetUsername(),
+ UsernameSet: true,
})
} else {
filteredMutations = append(filteredMutations, mutation)
@@ -291,6 +325,18 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa
eventToDispatch = &events.Contact{JID: jid, Timestamp: ts, Action: act, FromFullSync: fullSync}
if cli.Store.Contacts != nil {
storeUpdateError = cli.Store.Contacts.PutContactName(ctx, jid, act.GetFirstName(), act.GetFullName())
+ if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && storeUpdateError == nil {
+ storeUpdateError = usernameStore.PutContactUsername(ctx, jid, act.GetUsername())
+ }
+ }
+ case appstate.IndexLIDContact:
+ act := mutation.Action.GetLidContactAction()
+ eventToDispatch = &events.LIDContact{JID: jid, Timestamp: ts, Action: act, FromFullSync: fullSync}
+ if cli.Store.Contacts != nil {
+ storeUpdateError = cli.Store.Contacts.PutContactName(ctx, jid, act.GetFirstName(), act.GetFullName())
+ if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok && storeUpdateError == nil {
+ storeUpdateError = usernameStore.PutContactUsername(ctx, jid, act.GetUsername())
+ }
}
case appstate.IndexClearChat:
act := mutation.Action.GetClearChatAction()
@@ -417,6 +463,16 @@ func (cli *Client) dispatchAppState(ctx context.Context, name appstate.WAPatchNa
Action: act,
FromFullSync: fullSync,
}
+ case appstate.IndexQuickReply:
+ if len(mutation.Index) < 2 {
+ return
+ }
+ eventToDispatch = &events.QuickReply{
+ Timestamp: ts,
+ ID: mutation.Index[1],
+ Action: mutation.Action.GetQuickReplyAction(),
+ FromFullSync: fullSync,
+ }
}
if storeUpdateError != nil {
cli.Log.Errorf("Failed to update device store after app state mutation: %v", storeUpdateError)
diff --git a/appstate/decode.go b/appstate/decode.go
index 41dc2bee4..dacda378f 100644
--- a/appstate/decode.go
+++ b/appstate/decode.go
@@ -17,11 +17,11 @@ import (
"google.golang.org/protobuf/proto"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waServerSync"
- "go.mau.fi/whatsmeow/proto/waSyncAction"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/util/cbcutil"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waServerSync"
+ "github.com/polymorfa/hypermeow/proto/waSyncAction"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/util/cbcutil"
)
// PatchList represents a decoded response to getting app state patches from the WhatsApp servers.
diff --git a/appstate/encode.go b/appstate/encode.go
index c101a55c5..490643c28 100644
--- a/appstate/encode.go
+++ b/appstate/encode.go
@@ -9,11 +9,11 @@ import (
"google.golang.org/protobuf/proto"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waServerSync"
- "go.mau.fi/whatsmeow/proto/waSyncAction"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/util/cbcutil"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waServerSync"
+ "github.com/polymorfa/hypermeow/proto/waSyncAction"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/util/cbcutil"
)
// MutationInfo contains information about a single mutation to the app state.
@@ -162,6 +162,26 @@ func BuildLabelChat(target types.JID, labelID string, labeled bool) PatchInfo {
}
}
+type LabelChatChange struct {
+ LabelID string
+ Labeled bool
+}
+
+func BuildLabelChatChanges(target types.JID, changes []LabelChatChange) PatchInfo {
+ mutations := make([]MutationInfo, 0, len(changes))
+ mutationIndexes := make(map[string]int, len(changes))
+ for _, change := range changes {
+ mutation := newLabelChatMutation(target, change.LabelID, change.Labeled)
+ if index, exists := mutationIndexes[change.LabelID]; exists {
+ mutations[index] = mutation
+ } else {
+ mutationIndexes[change.LabelID] = len(mutations)
+ mutations = append(mutations, mutation)
+ }
+ }
+ return PatchInfo{Type: WAPatchRegular, Mutations: mutations}
+}
+
func newLabelMessageMutation(target types.JID, labelID, messageID string, labeled bool) MutationInfo {
return MutationInfo{
Index: []string{IndexLabelAssociationMessage, labelID, target.String(), messageID, "0", "0"},
@@ -208,6 +228,43 @@ func BuildLabelEdit(labelID string, labelName string, labelColor int32, deleted
}
}
+func newQuickReplyMutation(id, shortcut, message string, keywords []string, count int32, deleted bool, associatedLabelIDs []string) MutationInfo {
+ return MutationInfo{
+ Index: []string{IndexQuickReply, id},
+ Version: 2,
+ Value: &waSyncAction.SyncActionValue{
+ QuickReplyAction: &waSyncAction.QuickReplyAction{
+ Shortcut: proto.String(shortcut),
+ Message: proto.String(message),
+ Keywords: keywords,
+ Count: proto.Int32(count),
+ Deleted: proto.Bool(deleted),
+ AssociatedLabelIDs: associatedLabelIDs,
+ },
+ },
+ }
+}
+
+// BuildQuickReply builds an app state patch for adding or editing a quick reply.
+func BuildQuickReply(id, shortcut, message string, keywords []string, count int32, associatedLabelIDs ...string) PatchInfo {
+ return PatchInfo{
+ Type: WAPatchRegular,
+ Mutations: []MutationInfo{
+ newQuickReplyMutation(id, shortcut, message, keywords, count, false, associatedLabelIDs),
+ },
+ }
+}
+
+// BuildQuickReplyDelete builds an app state tombstone for deleting a quick reply.
+func BuildQuickReplyDelete(id string) PatchInfo {
+ return PatchInfo{
+ Type: WAPatchRegular,
+ Mutations: []MutationInfo{
+ newQuickReplyMutation(id, "", "", []string{}, 0, true, nil),
+ },
+ }
+}
+
func newSettingPushNameMutation(pushName string) MutationInfo {
return MutationInfo{
Index: []string{IndexSettingPushName},
diff --git a/appstate/encode_label_test.go b/appstate/encode_label_test.go
new file mode 100644
index 000000000..8878b0822
--- /dev/null
+++ b/appstate/encode_label_test.go
@@ -0,0 +1,45 @@
+package appstate
+
+import (
+ "testing"
+
+ "github.com/polymorfa/hypermeow/types"
+)
+
+func TestBuildLabelChatChangesUsesOnePatch(t *testing.T) {
+ target := types.NewJID("15551234567", types.DefaultUserServer)
+ patch := BuildLabelChatChanges(target, []LabelChatChange{
+ {LabelID: "1", Labeled: false},
+ {LabelID: "2", Labeled: true},
+ })
+ if patch.Type != WAPatchRegular || len(patch.Mutations) != 2 {
+ t.Fatalf("patch = %#v", patch)
+ }
+ for index, want := range []struct {
+ labelID string
+ labeled bool
+ }{{"1", false}, {"2", true}} {
+ mutation := patch.Mutations[index]
+ if mutation.Version != 3 || len(mutation.Index) != 3 || mutation.Index[1] != want.labelID || mutation.Index[2] != target.String() {
+ t.Fatalf("mutation %d = %#v", index, mutation)
+ }
+ if mutation.Value.GetLabelAssociationAction().GetLabeled() != want.labeled {
+ t.Fatalf("mutation %d labeled = %v", index, mutation.Value.GetLabelAssociationAction().GetLabeled())
+ }
+ }
+}
+
+func TestBuildLabelChatChangesDeduplicatesLabelIDs(t *testing.T) {
+ target := types.NewJID("15551234567", types.DefaultUserServer)
+ patch := BuildLabelChatChanges(target, []LabelChatChange{
+ {LabelID: "1", Labeled: false},
+ {LabelID: "2", Labeled: true},
+ {LabelID: "1", Labeled: true},
+ })
+ if len(patch.Mutations) != 2 {
+ t.Fatalf("mutation count = %d, want 2", len(patch.Mutations))
+ }
+ if patch.Mutations[0].Index[1] != "1" || !patch.Mutations[0].Value.GetLabelAssociationAction().GetLabeled() {
+ t.Fatalf("duplicate label did not preserve its final state: %#v", patch.Mutations[0])
+ }
+}
diff --git a/appstate/encode_quick_reply_test.go b/appstate/encode_quick_reply_test.go
new file mode 100644
index 000000000..964459def
--- /dev/null
+++ b/appstate/encode_quick_reply_test.go
@@ -0,0 +1,61 @@
+package appstate
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestBuildQuickReply(t *testing.T) {
+ patch := BuildQuickReply("1700000000", "hours", "We are open until 18:00.", []string{"open", "hours"}, 7)
+ if patch.Type != WAPatchRegular || len(patch.Mutations) != 1 {
+ t.Fatalf("patch = %#v", patch)
+ }
+ mutation := patch.Mutations[0]
+ if mutation.Version != 2 || !reflect.DeepEqual(mutation.Index, []string{IndexQuickReply, "1700000000"}) {
+ t.Fatalf("mutation = %#v", mutation)
+ }
+ action := mutation.Value.GetQuickReplyAction()
+ if action.GetDeleted() || action.GetShortcut() != "hours" || action.GetMessage() != "We are open until 18:00." || action.GetCount() != 7 {
+ t.Fatalf("action = %#v", action)
+ }
+ if !reflect.DeepEqual(action.GetKeywords(), []string{"open", "hours"}) || len(action.GetAssociatedLabelIDs()) != 0 {
+ t.Fatalf("action lists = %#v", action)
+ }
+}
+
+func TestBuildQuickReplyPreservesAssociatedLabels(t *testing.T) {
+ build := reflect.ValueOf(BuildQuickReply)
+ if !build.Type().IsVariadic() {
+ t.Fatal("BuildQuickReply does not accept associated label IDs")
+ }
+ result := build.CallSlice([]reflect.Value{
+ reflect.ValueOf("1700000000"),
+ reflect.ValueOf("hours"),
+ reflect.ValueOf("We are open until 18:00."),
+ reflect.ValueOf([]string{"open", "hours"}),
+ reflect.ValueOf(int32(7)),
+ reflect.ValueOf([]string{"label-1", "label-2"}),
+ })[0].Interface().(PatchInfo)
+ action := result.Mutations[0].Value.GetQuickReplyAction()
+ if !reflect.DeepEqual(action.GetAssociatedLabelIDs(), []string{"label-1", "label-2"}) {
+ t.Fatalf("associated labels = %#v", action.GetAssociatedLabelIDs())
+ }
+}
+
+func TestBuildQuickReplyDeleteUsesTombstone(t *testing.T) {
+ patch := BuildQuickReplyDelete("1700000000")
+ if patch.Type != WAPatchRegular || len(patch.Mutations) != 1 {
+ t.Fatalf("patch = %#v", patch)
+ }
+ mutation := patch.Mutations[0]
+ if mutation.Version != 2 || !reflect.DeepEqual(mutation.Index, []string{IndexQuickReply, "1700000000"}) {
+ t.Fatalf("mutation = %#v", mutation)
+ }
+ action := mutation.Value.GetQuickReplyAction()
+ if !action.GetDeleted() || action.GetShortcut() != "" || action.GetMessage() != "" || action.GetCount() != 0 {
+ t.Fatalf("action = %#v", action)
+ }
+ if len(action.GetKeywords()) != 0 || len(action.GetAssociatedLabelIDs()) != 0 {
+ t.Fatalf("action lists = %#v", action)
+ }
+}
diff --git a/appstate/hash.go b/appstate/hash.go
index a3070bff4..db7700ca9 100644
--- a/appstate/hash.go
+++ b/appstate/hash.go
@@ -14,9 +14,9 @@ import (
"fmt"
"hash"
- "go.mau.fi/whatsmeow/appstate/lthash"
- "go.mau.fi/whatsmeow/proto/waServerSync"
- "go.mau.fi/whatsmeow/proto/waSyncAction"
+ "github.com/polymorfa/hypermeow/appstate/lthash"
+ "github.com/polymorfa/hypermeow/proto/waServerSync"
+ "github.com/polymorfa/hypermeow/proto/waSyncAction"
)
type Mutation struct {
diff --git a/appstate/keys.go b/appstate/keys.go
index 97a2fdad1..04e69f4e9 100644
--- a/appstate/keys.go
+++ b/appstate/keys.go
@@ -12,9 +12,9 @@ import (
"encoding/base64"
"sync"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/util/hkdfutil"
- waLog "go.mau.fi/whatsmeow/util/log"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/util/hkdfutil"
+ waLog "github.com/polymorfa/hypermeow/util/log"
)
// WAPatchName represents a type of app state patch.
diff --git a/appstate/lthash/lthash.go b/appstate/lthash/lthash.go
index 8d3045d60..2cad11b3d 100644
--- a/appstate/lthash/lthash.go
+++ b/appstate/lthash/lthash.go
@@ -13,7 +13,7 @@ package lthash
import (
"encoding/binary"
- "go.mau.fi/whatsmeow/util/hkdfutil"
+ "github.com/polymorfa/hypermeow/util/hkdfutil"
)
type LTHash struct {
diff --git a/appstate/recovery.go b/appstate/recovery.go
index 40946c31c..a976b3796 100644
--- a/appstate/recovery.go
+++ b/appstate/recovery.go
@@ -17,10 +17,10 @@ import (
"google.golang.org/protobuf/proto"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/proto/waServerSync"
- "go.mau.fi/whatsmeow/proto/waSyncdSnapshotRecovery"
- "go.mau.fi/whatsmeow/store"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/proto/waServerSync"
+ "github.com/polymorfa/hypermeow/proto/waSyncdSnapshotRecovery"
+ "github.com/polymorfa/hypermeow/store"
)
func ParseRecovery(
diff --git a/appstate_label_events_test.go b/appstate_label_events_test.go
new file mode 100644
index 000000000..057859a8a
--- /dev/null
+++ b/appstate_label_events_test.go
@@ -0,0 +1,70 @@
+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/armadillomessage.go b/armadillomessage.go
index aea351857..090071962 100644
--- a/armadillomessage.go
+++ b/armadillomessage.go
@@ -12,14 +12,14 @@ import (
"google.golang.org/protobuf/proto"
- armadillo "go.mau.fi/whatsmeow/proto"
- "go.mau.fi/whatsmeow/proto/armadilloutil"
- "go.mau.fi/whatsmeow/proto/instamadilloTransportPayload"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waMsgApplication"
- "go.mau.fi/whatsmeow/proto/waMsgTransport"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ armadillo "github.com/polymorfa/hypermeow/proto"
+ "github.com/polymorfa/hypermeow/proto/armadilloutil"
+ "github.com/polymorfa/hypermeow/proto/instamadilloTransportPayload"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waMsgApplication"
+ "github.com/polymorfa/hypermeow/proto/waMsgTransport"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
func (cli *Client) handleDecryptedArmadillo(ctx context.Context, info *types.MessageInfo, decrypted []byte, retryCount int) (handlerFailed, protobufFailed bool) {
diff --git a/benchmark/barback/Dockerfile b/benchmark/barback/Dockerfile
index 2a6008ca1..7a10afd31 100644
--- a/benchmark/barback/Dockerfile
+++ b/benchmark/barback/Dockerfile
@@ -2,14 +2,15 @@
FROM golang:1.26-bookworm@sha256:6c5605ab3a9a9fb3c4eafe5b3d63cdbf3881caf113262b67862547b54a9db599 AS build
ARG BUILD_REV=working-tree
+ARG BENCH_BUILD_TAGS
WORKDIR /src
COPY . .
COPY --from=library . /library
WORKDIR /src/benchmark/barback
-RUN go mod edit -replace=go.mau.fi/whatsmeow=/library
+RUN sh ./prepare-library-module.sh /library
RUN --mount=type=cache,target=/go/pkg/mod go mod tidy
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
- CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.revision=${BUILD_REV}" -o /out/hypermeow-bench ./cmd/bench
+ CGO_ENABLED=0 go build -tags="${BENCH_BUILD_TAGS}" -trimpath -ldflags="-s -w -X main.revision=${BUILD_REV}" -o /out/hypermeow-bench ./cmd/bench
FROM gcr.io/distroless/static-debian12:nonroot@sha256:f5b485ea962d9bd1186b2f6b3a061191539b905b82ec395de78cbfae51f20e35
COPY --from=build /out/hypermeow-bench /usr/local/bin/hypermeow-bench
diff --git a/benchmark/barback/Dockerfile.clientmem b/benchmark/barback/Dockerfile.clientmem
index de80d9970..505f5f73f 100644
--- a/benchmark/barback/Dockerfile.clientmem
+++ b/benchmark/barback/Dockerfile.clientmem
@@ -6,7 +6,7 @@ WORKDIR /src
COPY . .
COPY --from=library . /library
WORKDIR /src/benchmark/barback
-RUN go mod edit -replace=go.mau.fi/whatsmeow=/library
+RUN sh ./prepare-library-module.sh /library
RUN --mount=type=cache,target=/go/pkg/mod go mod tidy
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.revision=${BUILD_REV}" -o /out/clientmem ./cmd/clientmem
diff --git a/benchmark/barback/README.md b/benchmark/barback/README.md
index 2b346e88a..0d0b260eb 100644
--- a/benchmark/barback/README.md
+++ b/benchmark/barback/README.md
@@ -53,7 +53,7 @@ BENCH_REPEATS=3 ./run-comparison-matrix.sh
Resume an interrupted repeated matrix with `BENCH_REPEAT_START`, keeping `BENCH_REPEATS` set to the final repeat number.
-Set `CANDIDATE_REF` to benchmark a frozen candidate while using newer harness code. All three libraries are exported to temporary immutable contexts before the matrix starts.
+Set `CANDIDATE_REF` to benchmark a frozen candidate while using newer harness code. The comparison driver selects the legacy build mode when that revision lacks APIs imported by newer smoke checks. Set `CANDIDATE_BUILD_TAGS` explicitly to override this detection for a custom library. All three libraries are exported to temporary immutable contexts before the matrix starts.
The archived upstream baseline receives only `patches/barback-socket-config.patch`, the same URL, Origin, and Noise certificate-authority injection already present before PR #3. The patch is required to connect upstream WhatsMeow to Barback and contains no runtime optimization.
@@ -76,3 +76,17 @@ The frozen three-repeat comparison is summarized in `results/system-comparison.m
Set `MEM_PROFILE_PATH=/results/run.heap.pb.gz` to capture a full-rate Go allocation profile. Profiling changes runtime cost, so compare profiles with each other rather than with ordinary benchmark timings. Inspect cumulative allocations with `go tool pprof -top -alloc_space results/run.heap.pb.gz` and retained heap with `go tool pprof -top -inuse_space results/run.heap.pb.gz`.
Change only one workload dimension at a time. Recommended group sizes are 32, 128, 512, and 1024. Keep the Barback revision, mode, sender count, rate, total, history size, container limits, host power state, and Docker version fixed across a baseline/candidate pair.
+
+Set `BENCH_BUSINESS_SMOKE=true` to validate live catalog, product, collection, product-list, and order reads through the paired HyperMeow connection before accepting the benchmark result. The fixtures are generated by Barback and never contact a WhatsApp account.
+
+Set `BENCH_PHONE_CONSENT_SMOKE=true BARBACK_CAPTURE_MESSAGES=10` to send a
+request-phone-number message and a share-phone-number protocol message through
+Signal, then verify both decrypted protobufs in Barback's bounded capture
+buffer. This uses only the synthetic browser and fake phone identities.
+
+Set `BENCH_SECURITY_CODE_SMOKE=true` on `run-comparison-matrix.sh` to validate
+LID identity verification codes. The driver runs that check in a separate
+smoke-only stack, removes its PostgreSQL volume, and then starts the measured
+candidate from cold state. Direct Compose runs must also set
+`BENCH_SMOKE_ONLY=true`; combining this validation with a measured workload is
+rejected because it would warm the candidate's device and identity caches.
diff --git a/benchmark/barback/cmd/bench/business_app_smoke.go b/benchmark/barback/cmd/bench/business_app_smoke.go
new file mode 100644
index 000000000..6e1e23a2f
--- /dev/null
+++ b/benchmark/barback/cmd/bench/business_app_smoke.go
@@ -0,0 +1,62 @@
+//go:build !benchmark_legacy
+
+package main
+
+import (
+ "context"
+ "fmt"
+
+ whatsmeow "github.com/polymorfa/hypermeow"
+ "github.com/polymorfa/hypermeow/types"
+)
+
+func businessAppSmokeSupported() bool {
+ return true
+}
+
+func validateBusinessApp(ctx context.Context, client *whatsmeow.Client) error {
+ business := types.NewJID("15551234567", types.DefaultUserServer)
+ catalog, err := client.GetCatalog(ctx, business, whatsmeow.GetCatalogParams{Limit: 50})
+ if err != nil {
+ return fmt.Errorf("business app catalog: %w", err)
+ }
+ if len(catalog.Products) != 2 || catalog.Products[0].ID != "p-tea" || catalog.Products[0].Price != "1250" || catalog.Products[0].Currency != "USD" {
+ return fmt.Errorf("business app catalog returned an unexpected fixture")
+ }
+ product, err := client.GetCatalogProduct(ctx, business, "p-tea")
+ if err != nil {
+ return fmt.Errorf("business app product: %w", err)
+ }
+ if product.ID != "p-tea" || product.RetailerID != "sku-tea-20" {
+ return fmt.Errorf("business app product returned an unexpected fixture")
+ }
+ collections, err := client.GetProductCollections(ctx, business, whatsmeow.GetCollectionsParams{})
+ if err != nil {
+ return fmt.Errorf("business app collections: %w", err)
+ }
+ if len(collections.Collections) != 1 || collections.Collections[0].ID != "c-seasonal" {
+ return fmt.Errorf("business app collections returned an unexpected fixture")
+ }
+ collection, err := client.GetProductCollection(ctx, business, "c-seasonal", whatsmeow.GetCatalogParams{Limit: 50})
+ if err != nil {
+ return fmt.Errorf("business app collection: %w", err)
+ }
+ if collection.ID != "c-seasonal" || len(collection.Products) != 2 {
+ return fmt.Errorf("business app collection returned an unexpected fixture")
+ }
+ products, err := client.GetCatalogProducts(ctx, business, []string{"p-coffee", "p-tea"})
+ if err != nil {
+ return fmt.Errorf("business app product list: %w", err)
+ }
+ if len(products) != 2 || products[0].ID != "p-coffee" || products[1].ID != "p-tea" {
+ return fmt.Errorf("business app product list returned an unexpected fixture")
+ }
+ order, err := client.GetOrderDetails(ctx, "o-100", "synthetic-token")
+ if err != nil {
+ return fmt.Errorf("business app order: %w", err)
+ }
+ if order.ID != "o-100" || order.Price.Total != 2650 || len(order.Products) != 2 {
+ return fmt.Errorf("business app order returned an unexpected fixture")
+ }
+ return nil
+}
diff --git a/benchmark/barback/cmd/bench/business_app_smoke_legacy.go b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go
new file mode 100644
index 000000000..4822a6b7e
--- /dev/null
+++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy.go
@@ -0,0 +1,17 @@
+//go:build benchmark_legacy
+
+package main
+
+import (
+ "context"
+
+ whatsmeow "github.com/polymorfa/hypermeow"
+)
+
+func businessAppSmokeSupported() bool {
+ return false
+}
+
+func validateBusinessApp(context.Context, *whatsmeow.Client) error {
+ return nil
+}
diff --git a/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go b/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go
new file mode 100644
index 000000000..9000831c8
--- /dev/null
+++ b/benchmark/barback/cmd/bench/business_app_smoke_legacy_test.go
@@ -0,0 +1,17 @@
+//go:build benchmark_legacy
+
+package main
+
+import (
+ "context"
+ "testing"
+)
+
+func TestLegacyBusinessAppValidationIsSkipped(t *testing.T) {
+ if businessAppSmokeSupported() {
+ t.Fatal("legacy baseline reported business smoke support")
+ }
+ if err := validateBusinessApp(context.Background(), nil); err != nil {
+ t.Fatalf("legacy baseline rejected business smoke validation: %v", err)
+ }
+}
diff --git a/benchmark/barback/cmd/bench/main.go b/benchmark/barback/cmd/bench/main.go
index 00d3cf86d..d3a267774 100644
--- a/benchmark/barback/cmd/bench/main.go
+++ b/benchmark/barback/cmd/bench/main.go
@@ -6,6 +6,7 @@ import (
"crypto/tls"
"crypto/x509"
"database/sql"
+ "encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -24,27 +25,37 @@ import (
"time"
_ "github.com/jackc/pgx/v5/stdlib"
- "go.mau.fi/whatsmeow"
- "go.mau.fi/whatsmeow/store/sqlstore"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- waLog "go.mau.fi/whatsmeow/util/log"
+
+ "google.golang.org/protobuf/proto"
+
+ whatsmeow "github.com/polymorfa/hypermeow"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/store/sqlstore"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
+ waLog "github.com/polymorfa/hypermeow/util/log"
)
var revision = "working-tree"
+const phoneConsentSmokeTimeout = 5 * time.Second
+
type config struct {
- DatabaseURL string
- BarbackURL string
- BarbackWS string
- TLSCAPath string
- TLSServerName string
- OutputPath string
- MemProfilePath string
- Variant string
- Total int64
- Timeout time.Duration
- Workload workloadConfig
+ DatabaseURL string
+ BarbackURL string
+ BarbackWS string
+ TLSCAPath string
+ TLSServerName string
+ OutputPath string
+ MemProfilePath string
+ Variant string
+ BusinessSmoke bool
+ PhoneConsentSmoke bool
+ SecurityCodeSmoke bool
+ SmokeOnly bool
+ Total int64
+ Timeout time.Duration
+ Workload workloadConfig
}
type workloadConfig struct {
@@ -96,33 +107,37 @@ type runtimeStats struct {
}
type result struct {
- Variant string `json:"variant"`
- Revision string `json:"revision"`
- StartedAt time.Time `json:"started_at"`
- Completed bool `json:"completed"`
- Error string `json:"error,omitempty"`
- TargetMessages int64 `json:"target_messages"`
- Workload workloadConfig `json:"workload"`
- MessagesReceived int64 `json:"messages_received"`
- MessagesSent int64 `json:"messages_sent"`
- SendFailures int64 `json:"send_failures"`
- FailureReasons map[string]int64 `json:"failure_reasons,omitempty"`
- QueueOverflows int64 `json:"queue_overflows"`
- HistorySyncs int64 `json:"history_syncs"`
- HistoryConversations int64 `json:"history_conversations"`
- HistoryMessages int64 `json:"history_messages"`
- DurationMS float64 `json:"duration_ms"`
- ThroughputPerSec float64 `json:"throughput_per_sec"`
- SendLatency latencyStats `json:"send_latency"`
- Database databaseStats `json:"database"`
- Runtime runtimeStats `json:"runtime"`
- WorkloadRuntime workloadRuntimeStats `json:"workload_runtime"`
- SessionRuntime workloadRuntimeStats `json:"session_runtime"`
- Resources resourceStats `json:"resources"`
- MessageTypes map[string]int64 `json:"message_types"`
- MediaUploads int64 `json:"media_uploads"`
- MediaUploadBytes int64 `json:"media_upload_bytes"`
- MediaUploadLatency latencyStats `json:"media_upload_latency"`
+ Variant string `json:"variant"`
+ Revision string `json:"revision"`
+ StartedAt time.Time `json:"started_at"`
+ Completed bool `json:"completed"`
+ Error string `json:"error,omitempty"`
+ TargetMessages int64 `json:"target_messages"`
+ Workload workloadConfig `json:"workload"`
+ MessagesReceived int64 `json:"messages_received"`
+ MessagesSent int64 `json:"messages_sent"`
+ SendFailures int64 `json:"send_failures"`
+ FailureReasons map[string]int64 `json:"failure_reasons,omitempty"`
+ QueueOverflows int64 `json:"queue_overflows"`
+ HistorySyncs int64 `json:"history_syncs"`
+ HistoryConversations int64 `json:"history_conversations"`
+ HistoryMessages int64 `json:"history_messages"`
+ DurationMS float64 `json:"duration_ms"`
+ ThroughputPerSec float64 `json:"throughput_per_sec"`
+ SendLatency latencyStats `json:"send_latency"`
+ Database databaseStats `json:"database"`
+ Runtime runtimeStats `json:"runtime"`
+ WorkloadRuntime workloadRuntimeStats `json:"workload_runtime"`
+ SessionRuntime workloadRuntimeStats `json:"session_runtime"`
+ Resources resourceStats `json:"resources"`
+ MessageTypes map[string]int64 `json:"message_types"`
+ MediaUploads int64 `json:"media_uploads"`
+ MediaUploadBytes int64 `json:"media_upload_bytes"`
+ MediaUploadLatency latencyStats `json:"media_upload_latency"`
+ BusinessAppValidated bool `json:"business_app_validated"`
+ PhoneConsentValidated bool `json:"phone_consent_validated"`
+ SecurityCodeValidated bool `json:"security_code_validated"`
+ SmokeOnly bool `json:"smoke_only"`
}
type runner struct {
@@ -130,22 +145,35 @@ type runner struct {
db *sql.DB
httpClient *http.Client
- received atomic.Int64
- sent atomic.Int64
- failed atomic.Int64
- overflows atomic.Int64
- historySyncs atomic.Int64
- historyConvs atomic.Int64
- historyMessages atomic.Int64
- messageSequence atomic.Int64
- mediaUploads atomic.Int64
- mediaBytes atomic.Int64
+ received atomic.Int64
+ sent atomic.Int64
+ failed atomic.Int64
+ overflows atomic.Int64
+ historySyncs atomic.Int64
+ historyConvs atomic.Int64
+ historyMessages atomic.Int64
+ messageSequence atomic.Int64
+ mediaUploads atomic.Int64
+ mediaBytes atomic.Int64
+ businessValid atomic.Bool
+ phoneConsentValid atomic.Bool
+ phoneConsentOnce sync.Once
+ phoneConsentErr error
+ phoneConsentValidator func(context.Context, *whatsmeow.Client, types.JID) error
+ statementStatsReset func(context.Context) error
+ statementStatsSnapshot func() databaseStats
+ securityCodeValid atomic.Bool
+ securityCodeOnce sync.Once
+ securityCodeErr error
+ preflightOnce sync.Once
+ preflightErr error
startOnce sync.Once
doneOnce sync.Once
startedAt atomic.Int64
finishedAt atomic.Int64
done chan struct{}
+ fatalErr chan error
jobs []chan *events.Message
latencyMu sync.Mutex
@@ -154,15 +182,20 @@ type runner struct {
messageTypes map[string]int64
failureReasons map[string]int64
- metricsMu sync.Mutex
- runtimeStart runtimeStats
- sessionStart runtimeStats
- resourceStart resourceStats
- metricsStarted bool
- sessionStarted bool
- tempStop chan struct{}
- tempPeakBytes atomic.Int64
- tempPeakFiles atomic.Int64
+ metricsMu sync.Mutex
+ runtimeStart runtimeStats
+ sessionStart runtimeStats
+ resourceStart resourceStats
+ metricsStarted bool
+ sessionStarted bool
+ preflightDatabase databaseStats
+ preflightCaptured bool
+ postConsentClean bool
+ tempStop chan struct{}
+ tempPeakBytes atomic.Int64
+ tempPeakFiles atomic.Int64
+ connected chan struct{}
+ connectedOnce sync.Once
}
func main() {
@@ -182,10 +215,12 @@ func main() {
r := &runner{
cfg: cfg,
done: make(chan struct{}),
+ fatalErr: make(chan error, 1),
jobs: make([]chan *events.Message, workerCount),
latencies: make([]float64, 0, cfg.Total),
messageTypes: make(map[string]int64),
failureReasons: make(map[string]int64),
+ connected: make(chan struct{}),
}
for i := range r.jobs {
r.jobs[i] = make(chan *events.Message, queueCapacity)
@@ -255,17 +290,43 @@ func loadConfig() (config, error) {
if err != nil {
return config{}, err
}
+ businessSmoke, err := strconv.ParseBool(env("BENCH_BUSINESS_SMOKE", "false"))
+ if err != nil {
+ return config{}, fmt.Errorf("invalid BENCH_BUSINESS_SMOKE")
+ }
+ phoneConsentSmoke, err := strconv.ParseBool(env("BENCH_PHONE_CONSENT_SMOKE", "false"))
+ if err != nil {
+ return config{}, fmt.Errorf("invalid BENCH_PHONE_CONSENT_SMOKE")
+ }
+ securityCodeSmoke, err := strconv.ParseBool(env("BENCH_SECURITY_CODE_SMOKE", "false"))
+ if err != nil {
+ return config{}, fmt.Errorf("invalid BENCH_SECURITY_CODE_SMOKE")
+ }
+ smokeOnly, err := strconv.ParseBool(env("BENCH_SMOKE_ONLY", "false"))
+ if err != nil {
+ return config{}, fmt.Errorf("invalid BENCH_SMOKE_ONLY")
+ }
+ if securityCodeSmoke && !smokeOnly {
+ return config{}, errors.New("BENCH_SECURITY_CODE_SMOKE requires BENCH_SMOKE_ONLY=true")
+ }
+ if smokeOnly && !phoneConsentSmoke && !securityCodeSmoke {
+ return config{}, errors.New("BENCH_SMOKE_ONLY requires a preflight smoke")
+ }
return config{
- DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@postgres:5432/hypermeow?sslmode=disable"),
- BarbackURL: env("BARBACK_URL", "http://barback:8080"),
- BarbackWS: env("BARBACK_WS", "ws://barback:8080/ws/chat"),
- TLSCAPath: os.Getenv("BARBACK_TLS_CA"),
- TLSServerName: env("BARBACK_TLS_SERVER_NAME", "0.0.0.0"),
- OutputPath: env("RESULT_PATH", "/results/result.json"),
- MemProfilePath: os.Getenv("MEM_PROFILE_PATH"),
- Variant: env("BENCH_VARIANT", "candidate"),
- Total: total,
- Timeout: timeout,
+ DatabaseURL: env("DATABASE_URL", "postgres://postgres:postgres@postgres:5432/hypermeow?sslmode=disable"),
+ BarbackURL: env("BARBACK_URL", "http://barback:8080"),
+ BarbackWS: env("BARBACK_WS", "ws://barback:8080/ws/chat"),
+ TLSCAPath: os.Getenv("BARBACK_TLS_CA"),
+ TLSServerName: env("BARBACK_TLS_SERVER_NAME", "0.0.0.0"),
+ OutputPath: env("RESULT_PATH", "/results/result.json"),
+ MemProfilePath: os.Getenv("MEM_PROFILE_PATH"),
+ Variant: env("BENCH_VARIANT", "candidate"),
+ BusinessSmoke: businessSmoke,
+ PhoneConsentSmoke: phoneConsentSmoke,
+ SecurityCodeSmoke: securityCodeSmoke,
+ SmokeOnly: smokeOnly,
+ Total: total,
+ Timeout: timeout,
Workload: workloadConfig{
Scenario: env("BENCH_SCENARIO", "custom"),
Mode: mode,
@@ -363,7 +424,10 @@ func (r *runner) run() (result, error) {
NoiseCertificateAuthority: &root,
}
client.EnableAutoReconnect = true
- client.AddEventHandler(r.handler(client))
+ if r.cfg.PhoneConsentSmoke {
+ enablePhoneConsentReceiveBarrier(client)
+ }
+ client.AddEventHandler(r.handler(ctx, client))
workerCtx, stopWorkers := context.WithCancel(ctx)
defer stopWorkers()
@@ -375,14 +439,43 @@ func (r *runner) run() (result, error) {
return r.snapshot(false), fmt.Errorf("connect: %w", err)
}
defer client.Disconnect()
+ if r.cfg.BusinessSmoke && businessAppSmokeSupported() {
+ connectionTimer := time.NewTimer(30 * time.Second)
+ defer connectionTimer.Stop()
+ select {
+ case <-r.connected:
+ case <-connectionTimer.C:
+ return r.snapshot(false), fmt.Errorf("business app validation: connection did not become ready")
+ case <-ctx.Done():
+ return r.snapshot(false), fmt.Errorf("business app validation: connection did not become ready: %w", ctx.Err())
+ }
+ if err = validateBusinessApp(ctx, client); err != nil {
+ return r.snapshot(false), err
+ }
+ r.businessValid.Store(true)
+ }
+
+ if err = r.waitForRunCompletion(ctx); err != nil {
+ return r.snapshot(false), err
+ }
+ time.Sleep(2 * time.Second)
+ res := r.snapshot(true)
+ return res, nil
+}
+func (r *runner) waitForRunCompletion(ctx context.Context) error {
select {
case <-r.done:
- time.Sleep(2 * time.Second)
- res := r.snapshot(true)
- return res, nil
+ select {
+ case err := <-r.fatalErr:
+ return err
+ default:
+ return nil
+ }
+ case err := <-r.fatalErr:
+ return err
case <-ctx.Done():
- return r.snapshot(false), fmt.Errorf("benchmark incomplete: %w", ctx.Err())
+ return fmt.Errorf("benchmark incomplete: %w", ctx.Err())
}
}
@@ -395,7 +488,7 @@ func (r *runner) stopMetricsSampler() {
r.metricsMu.Unlock()
}
-func (r *runner) handler(client *whatsmeow.Client) whatsmeow.EventHandler {
+func (r *runner) handler(ctx context.Context, client *whatsmeow.Client) whatsmeow.EventHandler {
var scanOnce sync.Once
return func(evt any) {
switch event := evt.(type) {
@@ -404,7 +497,12 @@ func (r *runner) handler(client *whatsmeow.Client) whatsmeow.EventHandler {
scanOnce.Do(func() { go r.scanQR(event.Codes[0]) })
}
case *events.Connected:
- if _, err := r.db.ExecContext(context.Background(), "SELECT pg_stat_statements_reset()"); err != nil {
+ r.connectedOnce.Do(func() {
+ if r.connected != nil {
+ close(r.connected)
+ }
+ })
+ if err := r.resetStatementStats(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "reset statement stats: %v\n", err)
}
r.startSessionMetrics()
@@ -419,6 +517,26 @@ func (r *runner) handler(client *whatsmeow.Client) whatsmeow.EventHandler {
if event.Message.GetConversation() != "ping" {
return
}
+ validatePhoneConsent := r.phoneConsentValidator
+ if validatePhoneConsent == nil {
+ validatePhoneConsent = r.validatePhoneNumberConsent
+ }
+ if !r.runBenchmarkPreflight(ctx, func() bool {
+ return r.beforeBenchmarkMessage(
+ func() error {
+ return validatePhoneConsent(ctx, client, phoneConsentTarget(event))
+ },
+ func() error {
+ return validateIdentityVerificationCodes(ctx, client, phoneConsentTarget(event))
+ },
+ )
+ }) {
+ return
+ }
+ if r.cfg.SmokeOnly {
+ r.doneOnce.Do(func() { close(r.done) })
+ return
+ }
r.startOnce.Do(r.startMetrics)
r.received.Add(1)
jobs := r.jobs[jobShard(event.Info.Chat, len(r.jobs))]
@@ -433,6 +551,27 @@ func (r *runner) handler(client *whatsmeow.Client) whatsmeow.EventHandler {
}
}
+func (r *runner) resetStatementStats(ctx context.Context) error {
+ if r.statementStatsReset != nil {
+ return r.statementStatsReset(ctx)
+ }
+ if r.db == nil {
+ return nil
+ }
+ _, err := r.db.ExecContext(ctx, "SELECT pg_stat_statements_reset()")
+ return err
+}
+
+func (r *runner) snapshotStatementStats() databaseStats {
+ if r.statementStatsSnapshot != nil {
+ return r.statementStatsSnapshot()
+ }
+ if r.db == nil {
+ return databaseStats{}
+ }
+ return databaseSnapshot(r.db)
+}
+
func (r *runner) startSessionMetrics() {
r.metricsMu.Lock()
if !r.sessionStarted {
@@ -447,13 +586,14 @@ func (r *runner) startMetrics() {
r.runtimeStart = runtimeSnapshot()
r.resourceStart = resourceSnapshot()
r.metricsStarted = true
- r.tempStop = make(chan struct{})
+ tempStop := make(chan struct{})
+ r.tempStop = tempStop
r.metricsMu.Unlock()
- go r.sampleTemporaryFiles()
+ go r.sampleTemporaryFiles(tempStop)
r.startedAt.Store(time.Now().UnixNano())
}
-func (r *runner) sampleTemporaryFiles() {
+func (r *runner) sampleTemporaryFiles(stop <-chan struct{}) {
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
@@ -461,7 +601,7 @@ func (r *runner) sampleTemporaryFiles() {
updatePeak(&r.tempPeakBytes, bytes)
updatePeak(&r.tempPeakFiles, files)
select {
- case <-r.tempStop:
+ case <-stop:
return
case <-ticker.C:
}
@@ -548,6 +688,165 @@ func (r *runner) sendWorker(ctx context.Context, client *whatsmeow.Client, jobs
}
}
+func (r *runner) runBenchmarkPreflight(ctx context.Context, validate func() bool) bool {
+ if !r.cfg.PhoneConsentSmoke && !r.cfg.SecurityCodeSmoke {
+ return true
+ }
+ r.preflightOnce.Do(func() {
+ preflightDatabase := r.snapshotStatementStats()
+ r.metricsMu.Lock()
+ r.preflightDatabase = preflightDatabase
+ r.preflightCaptured = true
+ r.metricsMu.Unlock()
+ valid := validate()
+ resetErr := r.resetStatementStats(ctx)
+ r.metricsMu.Lock()
+ r.postConsentClean = resetErr == nil
+ r.metricsMu.Unlock()
+ if !valid {
+ r.preflightErr = errors.New("benchmark smoke validation failed")
+ }
+ if resetErr != nil {
+ r.recordFailure(resetErr)
+ if r.preflightErr == nil {
+ r.preflightErr = fmt.Errorf("reset workload statement stats: %w", resetErr)
+ }
+ r.reportFatal(fmt.Errorf("reset workload statement stats: %w", resetErr))
+ }
+ })
+ return r.preflightErr == nil
+}
+
+func (r *runner) beforeBenchmarkMessage(validatePhoneConsent func() error, validateSecurityCodes ...func() error) bool {
+ if r.cfg.PhoneConsentSmoke && r.runPhoneConsentSmoke(validatePhoneConsent) != nil {
+ return false
+ }
+ if r.cfg.SecurityCodeSmoke {
+ if len(validateSecurityCodes) == 0 {
+ return false
+ }
+ if r.runSecurityCodeSmoke(validateSecurityCodes[0]) != nil {
+ return false
+ }
+ }
+ return true
+}
+
+func (r *runner) runPhoneConsentSmoke(validate func() error) error {
+ r.phoneConsentOnce.Do(func() {
+ r.phoneConsentErr = validate()
+ if r.phoneConsentErr == nil {
+ r.phoneConsentValid.Store(true)
+ return
+ }
+ r.recordFailure(r.phoneConsentErr)
+ fmt.Fprintf(os.Stderr, "validate phone number consent: %v\n", r.phoneConsentErr)
+ r.reportFatal(fmt.Errorf("validate phone number consent: %w", r.phoneConsentErr))
+ })
+ return r.phoneConsentErr
+}
+
+func (r *runner) runSecurityCodeSmoke(validate func() error) error {
+ r.securityCodeOnce.Do(func() {
+ r.securityCodeErr = validate()
+ if r.securityCodeErr == nil {
+ r.securityCodeValid.Store(true)
+ return
+ }
+ r.recordFailure(r.securityCodeErr)
+ fmt.Fprintf(os.Stderr, "validate identity verification code: %v\n", r.securityCodeErr)
+ r.reportFatal(fmt.Errorf("validate identity verification code: %w", r.securityCodeErr))
+ })
+ return r.securityCodeErr
+}
+
+func (r *runner) reportFatal(err error) {
+ if err == nil || r.fatalErr == nil {
+ return
+ }
+ select {
+ case r.fatalErr <- err:
+ default:
+ }
+}
+
+func phoneConsentTarget(message *events.Message) types.JID {
+ if message.Info.SenderAlt.Server == types.HiddenUserServer {
+ return message.Info.SenderAlt.ToNonAD()
+ }
+ if message.Info.Sender.Server == types.HiddenUserServer {
+ return message.Info.Sender.ToNonAD()
+ }
+ return message.Info.Chat.ToNonAD()
+}
+
+type capturedMessage struct {
+ PlaintextBase64 string `json:"plaintext_base64"`
+}
+
+func containsPhoneNumberConsentCaptures(captures []capturedMessage) bool {
+ var requestFound, shareFound bool
+ for _, capture := range captures {
+ plaintext, err := base64.StdEncoding.DecodeString(capture.PlaintextBase64)
+ if err != nil {
+ continue
+ }
+ var message waE2E.Message
+ if proto.Unmarshal(plaintext, &message) != nil {
+ continue
+ }
+ requestFound = requestFound || message.GetRequestPhoneNumberMessage() != nil
+ shareFound = shareFound || message.GetProtocolMessage().GetType() == waE2E.ProtocolMessage_SHARE_PHONE_NUMBER
+ }
+ return requestFound && shareFound
+}
+
+func buildRequestPhoneNumberMessage() *waE2E.Message {
+ return &waE2E.Message{RequestPhoneNumberMessage: &waE2E.RequestPhoneNumberMessage{}}
+}
+
+func buildSharePhoneNumberMessage() *waE2E.Message {
+ messageType := waE2E.ProtocolMessage_SHARE_PHONE_NUMBER
+ return &waE2E.Message{ProtocolMessage: &waE2E.ProtocolMessage{Type: &messageType}}
+}
+
+func (r *runner) validatePhoneNumberConsent(ctx context.Context, client *whatsmeow.Client, chat types.JID) error {
+ if chat.Server != types.HiddenUserServer {
+ return fmt.Errorf("synthetic phone consent target is not a LID")
+ }
+ smokeCtx, cancel := context.WithTimeout(ctx, phoneConsentSmokeTimeout)
+ defer cancel()
+ if _, err := client.SendMessage(smokeCtx, chat, buildRequestPhoneNumberMessage()); err != nil {
+ return fmt.Errorf("send request phone number message: %w", err)
+ }
+ if _, err := client.SendMessage(smokeCtx, chat, buildSharePhoneNumberMessage()); err != nil {
+ return fmt.Errorf("send share phone number message: %w", err)
+ }
+
+ ticker := time.NewTicker(50 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ req, err := http.NewRequestWithContext(smokeCtx, http.MethodGet, r.cfg.BarbackURL+"/admin/mock-phone/captured-messages", nil)
+ if err != nil {
+ return err
+ }
+ resp, err := r.httpClient.Do(req)
+ if err == nil {
+ var captures []capturedMessage
+ decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&captures)
+ _ = resp.Body.Close()
+ if resp.StatusCode == http.StatusOK && decodeErr == nil && containsPhoneNumberConsentCaptures(captures) {
+ return nil
+ }
+ }
+ select {
+ case <-smokeCtx.Done():
+ return fmt.Errorf("Barback did not capture request and share phone number messages: %w", smokeCtx.Err())
+ case <-ticker.C:
+ }
+ }
+}
+
func (r *runner) checkDone() {
if r.sent.Load()+r.failed.Load() >= r.cfg.Total {
r.doneOnce.Do(func() {
@@ -572,28 +871,39 @@ func (r *runner) snapshot(completed bool) result {
}
}
runtimeNow := runtimeSnapshot()
+ targetMessages := r.cfg.Total
+ workloadCompleted := r.sent.Load() == r.cfg.Total
+ if r.cfg.SmokeOnly {
+ targetMessages = 0
+ workloadCompleted = (!r.cfg.PhoneConsentSmoke || r.phoneConsentValid.Load()) &&
+ (!r.cfg.SecurityCodeSmoke || r.securityCodeValid.Load())
+ }
res := result{
- Variant: r.cfg.Variant,
- Revision: revision,
- StartedAt: startedTime,
- Completed: completed && r.failed.Load() == 0 && r.sent.Load() == r.cfg.Total,
- TargetMessages: r.cfg.Total,
- Workload: r.cfg.Workload,
- MessagesReceived: r.received.Load(),
- MessagesSent: r.sent.Load(),
- SendFailures: r.failed.Load(),
- FailureReasons: r.failureReasonSnapshot(),
- QueueOverflows: r.overflows.Load(),
- HistorySyncs: r.historySyncs.Load(),
- HistoryConversations: r.historyConvs.Load(),
- HistoryMessages: r.historyMessages.Load(),
- DurationMS: float64(duration) / float64(time.Millisecond),
- SendLatency: r.latencySnapshot(),
- Runtime: runtimeNow,
- MessageTypes: r.messageTypeSnapshot(),
- MediaUploads: r.mediaUploads.Load(),
- MediaUploadBytes: r.mediaBytes.Load(),
- MediaUploadLatency: r.uploadLatencySnapshot(),
+ Variant: r.cfg.Variant,
+ Revision: revision,
+ StartedAt: startedTime,
+ Completed: completed && r.failed.Load() == 0 && workloadCompleted,
+ TargetMessages: targetMessages,
+ Workload: r.cfg.Workload,
+ MessagesReceived: r.received.Load(),
+ MessagesSent: r.sent.Load(),
+ SendFailures: r.failed.Load(),
+ FailureReasons: r.failureReasonSnapshot(),
+ QueueOverflows: r.overflows.Load(),
+ HistorySyncs: r.historySyncs.Load(),
+ HistoryConversations: r.historyConvs.Load(),
+ HistoryMessages: r.historyMessages.Load(),
+ DurationMS: float64(duration) / float64(time.Millisecond),
+ SendLatency: r.latencySnapshot(),
+ Runtime: runtimeNow,
+ MessageTypes: r.messageTypeSnapshot(),
+ MediaUploads: r.mediaUploads.Load(),
+ MediaUploadBytes: r.mediaBytes.Load(),
+ MediaUploadLatency: r.uploadLatencySnapshot(),
+ BusinessAppValidated: r.businessValid.Load(),
+ PhoneConsentValidated: r.phoneConsentValid.Load(),
+ SecurityCodeValidated: r.securityCodeValid.Load(),
+ SmokeOnly: r.cfg.SmokeOnly,
}
r.metricsMu.Lock()
if r.sessionStarted {
@@ -614,8 +924,17 @@ func (r *runner) snapshot(completed bool) result {
if duration > 0 {
res.ThroughputPerSec = float64(res.MessagesSent) / duration.Seconds()
}
- if r.db != nil {
- res.Database = databaseSnapshot(r.db)
+ if r.db != nil || r.statementStatsSnapshot != nil {
+ r.metricsMu.Lock()
+ preflightDatabase := r.preflightDatabase
+ preflightCaptured := r.preflightCaptured
+ postConsentClean := r.postConsentClean
+ r.metricsMu.Unlock()
+ if preflightCaptured && !postConsentClean {
+ res.Database = preflightDatabase
+ } else {
+ res.Database = mergeDatabaseStats(preflightDatabase, r.snapshotStatementStats())
+ }
}
return res
}
@@ -702,8 +1021,7 @@ func databaseSnapshot(db *sql.DB) databaseStats {
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
AND query ILIKE '%whatsmeow_%'
- ORDER BY calls DESC, total_exec_time DESC
- LIMIT 30`)
+ ORDER BY calls DESC, total_exec_time DESC`)
if err != nil {
return stats
}
@@ -719,6 +1037,39 @@ func databaseSnapshot(db *sql.DB) databaseStats {
return stats
}
+func mergeDatabaseStats(preflight, workload databaseStats) databaseStats {
+ workload.Calls += preflight.Calls
+ workload.TotalExecMS += preflight.TotalExecMS
+ workload.Rows += preflight.Rows
+
+ queries := make(map[string]queryStat, len(preflight.Queries)+len(workload.Queries))
+ for _, item := range append(append([]queryStat(nil), preflight.Queries...), workload.Queries...) {
+ merged := queries[item.Query]
+ merged.Query = item.Query
+ merged.Calls += item.Calls
+ merged.TotalExecMS += item.TotalExecMS
+ merged.Rows += item.Rows
+ queries[item.Query] = merged
+ }
+ workload.Queries = workload.Queries[:0]
+ for _, item := range queries {
+ workload.Queries = append(workload.Queries, item)
+ }
+ sort.Slice(workload.Queries, func(i, j int) bool {
+ if workload.Queries[i].Calls != workload.Queries[j].Calls {
+ return workload.Queries[i].Calls > workload.Queries[j].Calls
+ }
+ if workload.Queries[i].TotalExecMS != workload.Queries[j].TotalExecMS {
+ return workload.Queries[i].TotalExecMS > workload.Queries[j].TotalExecMS
+ }
+ return workload.Queries[i].Query < workload.Queries[j].Query
+ })
+ if len(workload.Queries) > 30 {
+ workload.Queries = workload.Queries[:30]
+ }
+ return workload
+}
+
func runtimeSnapshot() runtimeStats {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
diff --git a/benchmark/barback/cmd/bench/main_test.go b/benchmark/barback/cmd/bench/main_test.go
index 321984388..cc01df272 100644
--- a/benchmark/barback/cmd/bench/main_test.go
+++ b/benchmark/barback/cmd/bench/main_test.go
@@ -1,11 +1,21 @@
package main
import (
+ "context"
+ "encoding/base64"
+ "errors"
"strconv"
+ "sync"
+ "sync/atomic"
"testing"
"time"
- "go.mau.fi/whatsmeow/types"
+ "google.golang.org/protobuf/proto"
+
+ whatsmeow "github.com/polymorfa/hypermeow"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
func TestLoadConfigWorkload(t *testing.T) {
@@ -20,6 +30,10 @@ func TestLoadConfigWorkload(t *testing.T) {
t.Setenv("BENCH_GROUP_SIZE", "0")
t.Setenv("HISTORY_CONVERSATIONS", "10")
t.Setenv("HISTORY_MESSAGES", "5")
+ t.Setenv("BENCH_BUSINESS_SMOKE", "1")
+ t.Setenv("BENCH_PHONE_CONSENT_SMOKE", "1")
+ t.Setenv("BENCH_SECURITY_CODE_SMOKE", "1")
+ t.Setenv("BENCH_SMOKE_ONLY", "1")
cfg, err := loadConfig()
if err != nil {
@@ -34,6 +48,25 @@ func TestLoadConfigWorkload(t *testing.T) {
if cfg.Workload.HistoryConversations != 10 || cfg.Workload.HistoryMessages != 5 {
t.Fatalf("unexpected history workload: %+v", cfg.Workload)
}
+ if !cfg.BusinessSmoke {
+ t.Fatal("business smoke validation was not enabled")
+ }
+ if !cfg.PhoneConsentSmoke {
+ t.Fatal("phone consent smoke validation was not enabled")
+ }
+ if !cfg.SecurityCodeSmoke {
+ t.Fatal("security code smoke validation was not enabled")
+ }
+ if !cfg.SmokeOnly {
+ t.Fatal("smoke-only mode was not enabled")
+ }
+}
+
+func TestLoadConfigRequiresSecuritySmokeIsolation(t *testing.T) {
+ t.Setenv("BENCH_SECURITY_CODE_SMOKE", "true")
+ if _, err := loadConfig(); err == nil {
+ t.Fatal("security code smoke was accepted without smoke-only isolation")
+ }
}
func TestLoadConfigRejectsInvalidWorkload(t *testing.T) {
@@ -65,6 +98,339 @@ func TestLoadConfigRejectsInvalidMode(t *testing.T) {
}
}
+func TestRunnerReportsFatalSmokeFailure(t *testing.T) {
+ r := &runner{fatalErr: make(chan error, 1)}
+ sentinel := errors.New("phone consent failed")
+ r.reportFatal(sentinel)
+ select {
+ case err := <-r.fatalErr:
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("fatal error = %v", err)
+ }
+ default:
+ t.Fatal("fatal smoke failure was not propagated")
+ }
+}
+
+func TestWaitForRunCompletionWakesOnFatalSmokeFailure(t *testing.T) {
+ r := &runner{done: make(chan struct{}), fatalErr: make(chan error, 1)}
+ sentinel := errors.New("phone consent failed")
+ r.reportFatal(sentinel)
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ if err := r.waitForRunCompletion(ctx); !errors.Is(err, sentinel) {
+ t.Fatalf("completion error = %v, want %v", err, sentinel)
+ }
+}
+
+func TestPhoneConsentFailureIsSharedAcrossWorkers(t *testing.T) {
+ r := &runner{
+ fatalErr: make(chan error, 1),
+ failureReasons: make(map[string]int64),
+ }
+ sentinel := errors.New("phone consent failed")
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ var calls atomic.Int64
+ validate := func() error {
+ if calls.Add(1) == 1 {
+ close(entered)
+ }
+ <-release
+ return sentinel
+ }
+
+ var workers sync.WaitGroup
+ errs := make(chan error, 2)
+ for range 2 {
+ workers.Add(1)
+ go func() {
+ defer workers.Done()
+ errs <- r.runPhoneConsentSmoke(validate)
+ }()
+ }
+ <-entered
+ close(release)
+ workers.Wait()
+ close(errs)
+ for err := range errs {
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("worker error = %v, want %v", err, sentinel)
+ }
+ }
+ if calls.Load() != 1 {
+ t.Fatalf("validation calls = %d, want 1", calls.Load())
+ }
+ if r.failed.Load() != 0 {
+ t.Fatalf("security code validation changed workload send failures to %d", r.failed.Load())
+ }
+}
+
+func TestPhoneConsentSmokeRunsBeforeMeasuredMetrics(t *testing.T) {
+ r := &runner{
+ cfg: config{PhoneConsentSmoke: true},
+ fatalErr: make(chan error, 1),
+ failureReasons: make(map[string]int64),
+ }
+ if !r.beforeBenchmarkMessage(func() error {
+ if r.startedAt.Load() != 0 {
+ t.Fatal("workload metrics started before phone consent validation")
+ }
+ return nil
+ }, nil) {
+ t.Fatal("phone consent validation failed")
+ }
+ r.startOnce.Do(r.startMetrics)
+ t.Cleanup(r.stopMetricsSampler)
+ if r.startedAt.Load() == 0 {
+ t.Fatal("workload metrics did not start after phone consent validation")
+ }
+}
+
+func TestPhoneConsentFailureDoesNotStartMeasuredMetrics(t *testing.T) {
+ r := &runner{
+ cfg: config{PhoneConsentSmoke: true},
+ fatalErr: make(chan error, 1),
+ failureReasons: make(map[string]int64),
+ }
+ if r.beforeBenchmarkMessage(func() error { return errors.New("failed") }, nil) {
+ t.Fatal("failed phone consent validation allowed the workload to start")
+ }
+ if r.startedAt.Load() != 0 {
+ t.Fatal("failed phone consent validation started workload metrics")
+ }
+ if r.failed.Load() != 0 {
+ t.Fatalf("phone consent validation changed workload send failures to %d", r.failed.Load())
+ }
+}
+
+func TestPhoneConsentPreservesTriggeringPingDatabaseStats(t *testing.T) {
+ jobs := make(chan *events.Message, 1)
+ var resetCalls atomic.Int64
+ phase := 0
+ r := &runner{
+ cfg: config{PhoneConsentSmoke: true, Total: 1},
+ fatalErr: make(chan error, 1),
+ failureReasons: make(map[string]int64),
+ jobs: []chan *events.Message{jobs},
+ }
+ r.statementStatsSnapshot = func() databaseStats {
+ switch phase {
+ case 0:
+ phase = 1
+ return databaseStats{
+ Calls: 2, TotalExecMS: 3, Rows: 4, SizeBytes: 100,
+ Queries: []queryStat{{Query: "SELECT whatsmeow_session", Calls: 2, TotalExecMS: 3, Rows: 4}},
+ }
+ case 3:
+ phase = 4
+ return databaseStats{
+ Calls: 5, TotalExecMS: 7, Rows: 11, SizeBytes: 200,
+ Queries: []queryStat{
+ {Query: "SELECT whatsmeow_session", Calls: 1, TotalExecMS: 2, Rows: 3},
+ {Query: "UPDATE whatsmeow_device", Calls: 4, TotalExecMS: 5, Rows: 8},
+ },
+ }
+ default:
+ t.Fatalf("statement stats snapshot at phase %d", phase)
+ return databaseStats{}
+ }
+ }
+ r.phoneConsentValidator = func(context.Context, *whatsmeow.Client, types.JID) error {
+ if phase != 1 || resetCalls.Load() != 0 {
+ t.Fatalf("phone consent validation at phase %d after %d resets", phase, resetCalls.Load())
+ }
+ phase = 2
+ return nil
+ }
+ r.statementStatsReset = func(context.Context) error {
+ if phase != 2 || r.startedAt.Load() != 0 {
+ t.Fatalf("statement stats reset at phase %d after workload metrics started", phase)
+ }
+ phase = 3
+ resetCalls.Add(1)
+ return nil
+ }
+ r.handler(context.Background(), &whatsmeow.Client{})(&events.Message{
+ Info: types.MessageInfo{MessageSource: types.MessageSource{Chat: types.NewJID("100000011111111", types.HiddenUserServer)}},
+ Message: &waE2E.Message{Conversation: proto.String("ping")},
+ })
+ t.Cleanup(r.stopMetricsSampler)
+ if resetCalls.Load() != 1 {
+ t.Fatalf("statement stats reset calls = %d, want 1", resetCalls.Load())
+ }
+ if r.startedAt.Load() == 0 {
+ t.Fatal("workload metrics did not start after statement stats reset")
+ }
+ result := r.snapshot(false)
+ if phase != 4 {
+ t.Fatalf("final statement stats snapshot left phase at %d", phase)
+ }
+ if result.Database.Calls != 7 || result.Database.TotalExecMS != 10 || result.Database.Rows != 15 {
+ t.Fatalf("database totals omitted triggering ping: %+v", result.Database)
+ }
+ if result.Database.SizeBytes != 200 {
+ t.Fatalf("database size = %d, want final size 200", result.Database.SizeBytes)
+ }
+ if len(result.Database.Queries) != 2 || result.Database.Queries[0].Query != "UPDATE whatsmeow_device" ||
+ result.Database.Queries[1].Calls != 3 || result.Database.Queries[1].TotalExecMS != 5 || result.Database.Queries[1].Rows != 7 {
+ t.Fatalf("database query stats were not merged: %+v", result.Database.Queries)
+ }
+}
+
+func TestPhoneConsentFailureExcludesValidationDatabaseStats(t *testing.T) {
+ phase := 0
+ r := &runner{
+ cfg: config{PhoneConsentSmoke: true},
+ fatalErr: make(chan error, 1),
+ failureReasons: make(map[string]int64),
+ phoneConsentValidator: func(context.Context, *whatsmeow.Client, types.JID) error {
+ if phase != 1 {
+ t.Fatalf("validation at phase %d", phase)
+ }
+ phase = 2
+ return errors.New("capture failed")
+ },
+ }
+ r.statementStatsSnapshot = func() databaseStats {
+ switch phase {
+ case 0:
+ phase = 1
+ return databaseStats{Calls: 3, Queries: []queryStat{{Query: "SELECT whatsmeow_session", Calls: 3}}}
+ case 3:
+ phase = 4
+ return databaseStats{}
+ default:
+ t.Fatalf("statement stats snapshot at phase %d", phase)
+ return databaseStats{}
+ }
+ }
+ r.statementStatsReset = func(context.Context) error {
+ if phase != 2 {
+ t.Fatalf("reset at phase %d", phase)
+ }
+ phase = 3
+ return nil
+ }
+ r.handler(context.Background(), &whatsmeow.Client{})(&events.Message{
+ Info: types.MessageInfo{MessageSource: types.MessageSource{Chat: types.NewJID("100000011111111", types.HiddenUserServer)}},
+ Message: &waE2E.Message{Conversation: proto.String("ping")},
+ })
+ result := r.snapshot(false)
+ if phase != 4 || result.Database.Calls != 3 || len(result.Database.Queries) != 1 {
+ t.Fatalf("failed consent polluted database stats at phase %d: %+v", phase, result.Database)
+ }
+ if r.failed.Load() != 0 {
+ t.Fatalf("failed consent changed workload failures to %d", r.failed.Load())
+ }
+}
+
+func TestHandlerUsesRunContextForPhoneConsent(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ r := &runner{
+ cfg: config{PhoneConsentSmoke: true},
+ fatalErr: make(chan error, 1),
+ failureReasons: make(map[string]int64),
+ phoneConsentValidator: func(ctx context.Context, _ *whatsmeow.Client, _ types.JID) error {
+ return ctx.Err()
+ },
+ }
+ r.handler(ctx, &whatsmeow.Client{})(&events.Message{
+ Info: types.MessageInfo{MessageSource: types.MessageSource{Chat: types.NewJID("100000011111111", types.HiddenUserServer)}},
+ Message: &waE2E.Message{Conversation: proto.String("ping")},
+ })
+ select {
+ case err := <-r.fatalErr:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("phone consent error = %v, want context canceled", err)
+ }
+ default:
+ t.Fatal("canceled run context was not propagated to phone consent validation")
+ }
+}
+
+func TestOptionalSmokesFinishBeforeMeasuredMetrics(t *testing.T) {
+ r := &runner{
+ cfg: config{
+ PhoneConsentSmoke: true,
+ SecurityCodeSmoke: true,
+ },
+ fatalErr: make(chan error, 1),
+ failureReasons: make(map[string]int64),
+ }
+ var order []string
+ if !r.beforeBenchmarkMessage(
+ func() error {
+ order = append(order, "phone")
+ return nil
+ },
+ func() error {
+ if r.startedAt.Load() != 0 {
+ t.Fatal("workload metrics started before security code validation")
+ }
+ order = append(order, "security")
+ return nil
+ },
+ ) {
+ t.Fatal("preflight validation failed")
+ }
+ if len(order) != 2 || order[0] != "phone" || order[1] != "security" {
+ t.Fatalf("validation order = %v", order)
+ }
+}
+
+func TestSmokeOnlyResultCompletesWithoutMeasuredMessages(t *testing.T) {
+ r := &runner{cfg: config{SecurityCodeSmoke: true, SmokeOnly: true, Total: 100}}
+ r.securityCodeValid.Store(true)
+
+ result := r.snapshot(true)
+ if !result.Completed || result.TargetMessages != 0 || !result.SmokeOnly {
+ t.Fatalf("unexpected smoke-only result: %+v", result)
+ }
+}
+
+func TestSecurityCodeFailureIsSharedAcrossWorkers(t *testing.T) {
+ r := &runner{
+ fatalErr: make(chan error, 1),
+ failureReasons: make(map[string]int64),
+ }
+ sentinel := errors.New("security code failed")
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ var calls atomic.Int64
+ validate := func() error {
+ if calls.Add(1) == 1 {
+ close(entered)
+ }
+ <-release
+ return sentinel
+ }
+
+ var workers sync.WaitGroup
+ errs := make(chan error, 2)
+ for range 2 {
+ workers.Add(1)
+ go func() {
+ defer workers.Done()
+ errs <- r.runSecurityCodeSmoke(validate)
+ }()
+ }
+ <-entered
+ close(release)
+ workers.Wait()
+ close(errs)
+ for err := range errs {
+ if !errors.Is(err, sentinel) {
+ t.Fatalf("worker error = %v, want %v", err, sentinel)
+ }
+ }
+ if calls.Load() != 1 {
+ t.Fatalf("validation calls = %d, want 1", calls.Load())
+ }
+}
+
func TestLoadConfigRejectsInvalidMessageProfile(t *testing.T) {
t.Setenv("BENCH_MESSAGE_PROFILE", "production")
if _, err := loadConfig(); err == nil {
@@ -108,3 +474,61 @@ func TestJobShardDistributesChats(t *testing.T) {
t.Fatalf("chat sharding is too concentrated: %d shards", len(seen))
}
}
+
+func TestContainsPhoneNumberConsentCaptures(t *testing.T) {
+ requestPayload, err := proto.Marshal(buildRequestPhoneNumberMessage())
+ if err != nil {
+ t.Fatal(err)
+ }
+ sharePayload, err := proto.Marshal(buildSharePhoneNumberMessage())
+ if err != nil {
+ t.Fatal(err)
+ }
+ captures := []capturedMessage{{PlaintextBase64: base64.StdEncoding.EncodeToString(requestPayload)}}
+ if containsPhoneNumberConsentCaptures(captures) {
+ t.Fatal("request-only capture passed phone consent validation")
+ }
+ captures = append(captures, capturedMessage{PlaintextBase64: base64.StdEncoding.EncodeToString(sharePayload)})
+ if !containsPhoneNumberConsentCaptures(captures) {
+ t.Fatal("request and share messages were not detected")
+ }
+}
+
+func TestPhoneConsentTargetPrefersSenderLID(t *testing.T) {
+ message := &events.Message{Info: types.MessageInfo{MessageSource: types.MessageSource{
+ Chat: types.NewJID("15550001111", types.DefaultUserServer),
+ Sender: types.NewJID("15550001111", types.DefaultUserServer),
+ SenderAlt: types.NewJID("100000011111111", types.HiddenUserServer),
+ }}}
+ if got := phoneConsentTarget(message); got.String() != "100000011111111@lid" {
+ t.Fatalf("target = %s", got)
+ }
+}
+
+func TestMergeDatabaseStatsRanksAfterCombiningAllQueries(t *testing.T) {
+ preflight := databaseStats{Queries: make([]queryStat, 31)}
+ workload := databaseStats{Queries: make([]queryStat, 31)}
+ for index := range 30 {
+ preflight.Queries[index] = queryStat{Query: "preflight-" + strconv.Itoa(index), Calls: 2}
+ workload.Queries[index] = queryStat{Query: "workload-" + strconv.Itoa(index), Calls: 2}
+ }
+ preflight.Queries[30] = queryStat{Query: "combined", Calls: 1}
+ workload.Queries[30] = queryStat{Query: "combined", Calls: 1}
+
+ merged := mergeDatabaseStats(preflight, workload)
+ if len(merged.Queries) != 30 {
+ t.Fatalf("merged query count = %d, want 30", len(merged.Queries))
+ }
+ found := false
+ for _, query := range merged.Queries {
+ if query.Query == "combined" {
+ found = true
+ if query.Calls != 2 {
+ t.Fatalf("combined calls = %d, want 2", query.Calls)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("combined phase-local tail query was dropped before final ranking")
+ }
+}
diff --git a/benchmark/barback/cmd/bench/phone_consent_sync.go b/benchmark/barback/cmd/bench/phone_consent_sync.go
new file mode 100644
index 000000000..ac6a1eafb
--- /dev/null
+++ b/benchmark/barback/cmd/bench/phone_consent_sync.go
@@ -0,0 +1,9 @@
+//go:build !benchmark_legacy
+
+package main
+
+import whatsmeow "github.com/polymorfa/hypermeow"
+
+func enablePhoneConsentReceiveBarrier(client *whatsmeow.Client) {
+ client.DangerousInternals().SetSynchronousMessageNameUpdates(true)
+}
diff --git a/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go b/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go
new file mode 100644
index 000000000..3ed971349
--- /dev/null
+++ b/benchmark/barback/cmd/bench/phone_consent_sync_legacy.go
@@ -0,0 +1,7 @@
+//go:build benchmark_legacy
+
+package main
+
+import whatsmeow "github.com/polymorfa/hypermeow"
+
+func enablePhoneConsentReceiveBarrier(_ *whatsmeow.Client) {}
diff --git a/benchmark/barback/cmd/bench/security_code_smoke.go b/benchmark/barback/cmd/bench/security_code_smoke.go
new file mode 100644
index 000000000..50f1866c7
--- /dev/null
+++ b/benchmark/barback/cmd/bench/security_code_smoke.go
@@ -0,0 +1,47 @@
+//go:build !benchmark_legacy
+
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "google.golang.org/protobuf/proto"
+
+ whatsmeow "github.com/polymorfa/hypermeow"
+ "github.com/polymorfa/hypermeow/proto/waFingerprint"
+ "github.com/polymorfa/hypermeow/types"
+)
+
+func validateIdentityVerificationCodes(ctx context.Context, client *whatsmeow.Client, userID types.JID) error {
+ if userID.Server != types.HiddenUserServer {
+ return whatsmeow.ErrIdentityVerificationRequiresLID
+ }
+ codes, err := client.GetIdentityVerificationCodes(ctx, userID)
+ if err != nil {
+ return fmt.Errorf("generate identity verification codes: %w", err)
+ }
+ if codes.UserID != userID || len(codes.NumericCode) != 60 {
+ return errors.New("identity verification result has an invalid LID or numeric code")
+ }
+ for _, digit := range codes.NumericCode {
+ if digit < '0' || digit > '9' {
+ return errors.New("identity verification numeric code contains non-digits")
+ }
+ }
+ var display, verification waFingerprint.CombinedFingerprint
+ if err = proto.Unmarshal(codes.DisplayQRCode, &display); err != nil {
+ return fmt.Errorf("decode display QR: %w", err)
+ }
+ if err = proto.Unmarshal(codes.VerificationQRCode, &verification); err != nil {
+ return fmt.Errorf("decode verification QR: %w", err)
+ }
+ if len(display.GetLocalFingerprint().GetPublicKey()) != 0 || len(display.GetRemoteFingerprint().GetPublicKey()) != 0 {
+ return errors.New("display QR exposed unhashed identity keys")
+ }
+ if len(verification.GetLocalFingerprint().GetPublicKey()) == 0 || len(verification.GetRemoteFingerprint().GetPublicKey()) == 0 {
+ return errors.New("verification QR omitted identity keys")
+ }
+ return nil
+}
diff --git a/benchmark/barback/cmd/bench/security_code_smoke_legacy.go b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go
new file mode 100644
index 000000000..437d488be
--- /dev/null
+++ b/benchmark/barback/cmd/bench/security_code_smoke_legacy.go
@@ -0,0 +1,14 @@
+//go:build benchmark_legacy
+
+package main
+
+import (
+ "context"
+
+ whatsmeow "github.com/polymorfa/hypermeow"
+ "github.com/polymorfa/hypermeow/types"
+)
+
+func validateIdentityVerificationCodes(context.Context, *whatsmeow.Client, types.JID) error {
+ return nil
+}
diff --git a/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go b/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go
new file mode 100644
index 000000000..8ba5fd4aa
--- /dev/null
+++ b/benchmark/barback/cmd/bench/security_code_smoke_legacy_test.go
@@ -0,0 +1,16 @@
+//go:build benchmark_legacy
+
+package main
+
+import (
+ "context"
+ "testing"
+
+ "github.com/polymorfa/hypermeow/types"
+)
+
+func TestLegacySecurityCodeValidationIsSkipped(t *testing.T) {
+ if err := validateIdentityVerificationCodes(context.Background(), nil, types.EmptyJID); err != nil {
+ t.Fatalf("legacy baseline rejected security-code smoke validation: %v", err)
+ }
+}
diff --git a/benchmark/barback/cmd/bench/workload_messages.go b/benchmark/barback/cmd/bench/workload_messages.go
index 6c7fab557..44208ac4d 100644
--- a/benchmark/barback/cmd/bench/workload_messages.go
+++ b/benchmark/barback/cmd/bench/workload_messages.go
@@ -7,10 +7,11 @@ import (
"sync"
"time"
- "go.mau.fi/whatsmeow"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waE2E"
"google.golang.org/protobuf/proto"
+
+ whatsmeow "github.com/polymorfa/hypermeow"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
)
var (
diff --git a/benchmark/barback/cmd/clientmem/main.go b/benchmark/barback/cmd/clientmem/main.go
index ed6e2beb1..fc0a7ed3e 100644
--- a/benchmark/barback/cmd/clientmem/main.go
+++ b/benchmark/barback/cmd/clientmem/main.go
@@ -9,9 +9,9 @@ import (
"strconv"
"syscall"
- "go.mau.fi/whatsmeow"
- "go.mau.fi/whatsmeow/store"
- waLog "go.mau.fi/whatsmeow/util/log"
+ whatsmeow "github.com/polymorfa/hypermeow"
+ "github.com/polymorfa/hypermeow/store"
+ waLog "github.com/polymorfa/hypermeow/util/log"
)
var revision = "working-tree"
diff --git a/benchmark/barback/comparison-matrix-env.sh b/benchmark/barback/comparison-matrix-env.sh
new file mode 100644
index 000000000..161edeb54
--- /dev/null
+++ b/benchmark/barback/comparison-matrix-env.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+comparison_security_code_smoke() {
+ local name=$1 build_tags=$2 requested=$3
+ if [[ $name != hypermeow || $build_tags =~ (^|[[:space:],])benchmark_legacy($|[[:space:],]) ]]; then
+ printf 'false\n'
+ else
+ printf '%s\n' "$requested"
+ fi
+}
diff --git a/benchmark/barback/comparison_matrix_test.go b/benchmark/barback/comparison_matrix_test.go
new file mode 100644
index 000000000..04ee6ac3a
--- /dev/null
+++ b/benchmark/barback/comparison_matrix_test.go
@@ -0,0 +1,36 @@
+package barback_test
+
+import (
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+func TestComparisonSecurityCodeSmoke(t *testing.T) {
+ tests := []struct {
+ name string
+ variant string
+ buildTags string
+ requested string
+ want string
+ }{
+ {name: "candidate", variant: "hypermeow", requested: "true", want: "true"},
+ {name: "legacy candidate", variant: "hypermeow", buildTags: "benchmark_legacy", requested: "true", want: "false"},
+ {name: "legacy candidate among tags", variant: "hypermeow", buildTags: "integration,benchmark_legacy", requested: "true", want: "false"},
+ {name: "upstream baseline", variant: "whatsmeow", buildTags: "benchmark_legacy", requested: "true", want: "false"},
+ {name: "disabled", variant: "hypermeow", requested: "false", want: "false"},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ command := `. ./comparison-matrix-env.sh; comparison_security_code_smoke "$1" "$2" "$3"`
+ output, err := exec.Command("bash", "-c", command, "bash", test.variant, test.buildTags, test.requested).CombinedOutput()
+ if err != nil {
+ t.Fatalf("evaluate security-code smoke: %v: %s", err, output)
+ }
+ if got := strings.TrimSpace(string(output)); got != test.want {
+ t.Fatalf("got %q, want %q", got, test.want)
+ }
+ })
+ }
+}
diff --git a/benchmark/barback/compose.dm.yaml b/benchmark/barback/compose.dm.yaml
index 4875a57f2..459ee54c3 100644
--- a/benchmark/barback/compose.dm.yaml
+++ b/benchmark/barback/compose.dm.yaml
@@ -22,3 +22,5 @@ services:
- ${HISTORY_CONVERSATIONS:-100}
- --history-sync-messages
- ${HISTORY_MESSAGES:-20}
+ - --capture-messages
+ - ${BARBACK_CAPTURE_MESSAGES:-0}
diff --git a/benchmark/barback/compose.yaml b/benchmark/barback/compose.yaml
index 5ad0f9698..71d034677 100644
--- a/benchmark/barback/compose.yaml
+++ b/benchmark/barback/compose.yaml
@@ -70,6 +70,8 @@ services:
- ${HISTORY_CONVERSATIONS:-100}
- --history-sync-messages
- ${HISTORY_MESSAGES:-20}
+ - --capture-messages
+ - ${BARBACK_CAPTURE_MESSAGES:-0}
depends_on:
cert-init:
condition: service_completed_successfully
@@ -95,6 +97,7 @@ services:
additional_contexts:
library: ${LIBRARY_CONTEXT:-../..}
args:
+ BENCH_BUILD_TAGS: ${BENCH_BUILD_TAGS:-}
BUILD_REV: ${BUILD_REV:-working-tree}
depends_on:
barback:
@@ -110,6 +113,10 @@ services:
BENCH_TOTAL: ${BENCH_TOTAL:-200}
BENCH_VARIANT: ${BENCH_VARIANT:-candidate}
BENCH_GROUP_SIZE: ${BENCH_GROUP_SIZE:-128}
+ BENCH_BUSINESS_SMOKE: ${BENCH_BUSINESS_SMOKE:-false}
+ BENCH_PHONE_CONSENT_SMOKE: ${BENCH_PHONE_CONSENT_SMOKE:-false}
+ BENCH_SECURITY_CODE_SMOKE: ${BENCH_SECURITY_CODE_SMOKE:-false}
+ BENCH_SMOKE_ONLY: ${BENCH_SMOKE_ONLY:-false}
BENCH_MODE: ${BENCH_MODE:-group}
BENCH_MESSAGE_PROFILE: ${BENCH_MESSAGE_PROFILE:-text}
BENCH_RATE: ${BENCH_RATE:-50}
diff --git a/benchmark/barback/go.mod b/benchmark/barback/go.mod
index ea1043693..6e26db10c 100644
--- a/benchmark/barback/go.mod
+++ b/benchmark/barback/go.mod
@@ -6,15 +6,13 @@ toolchain go1.26.5
require (
github.com/jackc/pgx/v5 v5.10.0
- go.mau.fi/whatsmeow v0.0.0
+ github.com/polymorfa/hypermeow v0.0.0
google.golang.org/protobuf v1.36.11
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
- github.com/beeper/argo-go v1.1.2 // indirect
github.com/coder/websocket v1.8.15 // indirect
- github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -24,7 +22,6 @@ require (
github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca // indirect
github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11 // indirect
github.com/rs/zerolog v1.35.1 // indirect
- github.com/vektah/gqlparser/v2 v2.5.27 // indirect
go.mau.fi/util v0.9.12-0.20260717235539-f9ffa7eca58d // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect
@@ -34,4 +31,4 @@ require (
golang.org/x/text v0.40.0 // indirect
)
-replace go.mau.fi/whatsmeow => ../..
+replace github.com/polymorfa/hypermeow => ../..
diff --git a/benchmark/barback/go.sum b/benchmark/barback/go.sum
index adccd192f..c38bccef7 100644
--- a/benchmark/barback/go.sum
+++ b/benchmark/barback/go.sum
@@ -2,19 +2,11 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
-github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
-github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
-github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
-github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
-github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
-github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
-github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -41,15 +33,11 @@ github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11
github.com/polymorfa/libsignal-protocol-go v0.2.3-0.20260806162910-a2adef2e8a11/go.mod h1:B5KZPsgswdf1eCvC+PZ7z45g5OECgqy6Zb0o7I5CcNQ=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
-github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
-github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
-github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
go.mau.fi/util v0.9.12-0.20260717235539-f9ffa7eca58d h1:bUGRidYrauEwLoBvGLLPPQ0lgQUecT0z/ND+vHz07jM=
go.mau.fi/util v0.9.12-0.20260717235539-f9ffa7eca58d/go.mod h1:xunp/oIQfFD68HHcNHfG0pOiHkvEtDhTweeIwKJ//+Q=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
diff --git a/benchmark/barback/patches/barback-socket-config.patch b/benchmark/barback/patches/barback-socket-config.patch
index eaa6d654c..3c3728bf7 100644
--- a/benchmark/barback/patches/barback-socket-config.patch
+++ b/benchmark/barback/patches/barback-socket-config.patch
@@ -1,16 +1,16 @@
diff --git a/client.go b/client.go
-index 313970d..17a33c3 100644
+index 9e333f8..d2b995b 100644
--- a/client.go
+++ b/client.go
-@@ -206,6 +206,7 @@ type Client struct {
+@@ -207,6 +207,7 @@ type Client struct {
// separate library for all the non-e2ee-related stuff like logging in.
// The library is currently embedded in mautrix-meta (https://github.com/mautrix/meta), but may be separated later.
MessengerConfig *MessengerConfig
+ SocketConfig *SocketConfig
RefreshCAT func(context.Context) error
- }
-
-@@ -221,6 +222,13 @@ type MessengerConfig struct {
+ // The user agent to use (for non-Messenger connections).
+ UserAgent string
+@@ -225,6 +226,13 @@ type MessengerConfig struct {
WebsocketURL string
}
@@ -24,9 +24,9 @@ index 313970d..17a33c3 100644
// Size of buffer for the channel that all incoming XML nodes go through.
// In general it shouldn't go past a few buffered messages, but the channel is big to be safe.
const handlerQueueSize = 2048
-@@ -557,6 +565,14 @@ func (cli *Client) unlockedConnect(ctx context.Context) error {
- //fs.HTTPHeaders.Set("Sec-Fetch-Mode", "websocket")
- //fs.HTTPHeaders.Set("Sec-Fetch-Site", "cross-site")
+@@ -568,6 +576,14 @@ func (cli *Client) unlockedConnect(ctx context.Context) error {
+ fs.URL = cli.MessengerConfig.WebsocketURL
+ fs.HTTPHeaders.Set("Origin", cli.MessengerConfig.BaseURL)
}
+ if cli.SocketConfig != nil {
+ if cli.SocketConfig.URL != "" {
@@ -36,9 +36,9 @@ index 313970d..17a33c3 100644
+ fs.HTTPHeaders.Set("Origin", cli.SocketConfig.Origin)
+ }
+ }
+ maps.Copy(fs.HTTPHeaders, cli.WebSocketHeaders)
if err := fs.Connect(ctx); err != nil {
fs.Close(0)
- return err
diff --git a/handshake.go b/handshake.go
index 8badf1a..8f9d86c 100644
--- a/handshake.go
diff --git a/benchmark/barback/prepare-library-module.sh b/benchmark/barback/prepare-library-module.sh
new file mode 100644
index 000000000..498d07017
--- /dev/null
+++ b/benchmark/barback/prepare-library-module.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+set -eu
+
+library_dir=${1:?library directory is required}
+module_path=$(sed -n 's/^module[[:space:]]\{1,\}//p' "$library_dir/go.mod" | head -n 1)
+
+case "$module_path" in
+ github.com/polymorfa/hypermeow)
+ go mod edit -replace=github.com/polymorfa/hypermeow="$library_dir"
+ ;;
+ go.mau.fi/whatsmeow)
+ find cmd -type f -name '*.go' -exec sed -i 's#github.com/polymorfa/hypermeow#go.mau.fi/whatsmeow#g' {} +
+ go mod edit -dropreplace=github.com/polymorfa/hypermeow
+ go mod edit -droprequire=github.com/polymorfa/hypermeow
+ go mod edit -require=go.mau.fi/whatsmeow@v0.0.0
+ go mod edit -replace=go.mau.fi/whatsmeow="$library_dir"
+ ;;
+ *)
+ echo "unsupported library module: $module_path" >&2
+ exit 2
+ ;;
+esac
diff --git a/benchmark/barback/run-comparison-matrix.sh b/benchmark/barback/run-comparison-matrix.sh
index 53da70a5d..d8ce250e5 100755
--- a/benchmark/barback/run-comparison-matrix.sh
+++ b/benchmark/barback/run-comparison-matrix.sh
@@ -4,6 +4,7 @@ set -euo pipefail
benchmark_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_dir=$(CDPATH= cd -- "$benchmark_dir/../.." && pwd)
temp_dir=$(mktemp -d)
+. "$benchmark_dir/comparison-matrix-env.sh"
cleanup() {
rm -rf -- "$temp_dir"
@@ -16,6 +17,29 @@ candidate_ref=${CANDIDATE_REF:-HEAD}
whatsmeow_sha=$(git -C "$repo_dir" rev-parse "$whatsmeow_ref^{commit}")
pre_pr3_sha=$(git -C "$repo_dir" rev-parse "$pre_pr3_ref^{commit}")
candidate_sha=$(git -C "$repo_dir" rev-parse "$candidate_ref^{commit}")
+candidate_build_tags=${CANDIDATE_BUILD_TAGS-}
+if [[ ! -v CANDIDATE_BUILD_TAGS ]]; then
+ required_candidate_symbols=(
+ 'func (cli *Client) GetCatalog('
+ 'func (cli *Client) GetCatalogProduct('
+ 'func (cli *Client) GetCatalogProducts('
+ 'func (cli *Client) GetProductCollection('
+ 'func (cli *Client) GetProductCollections('
+ 'func (cli *Client) GetOrderDetails('
+ )
+ if [[ -f "$benchmark_dir/cmd/bench/security_code_smoke.go" ]]; then
+ required_candidate_symbols+=('func (cli *Client) GetIdentityVerificationCodes(')
+ fi
+ if [[ -f "$benchmark_dir/cmd/bench/phone_consent_sync.go" ]]; then
+ required_candidate_symbols+=('func (int *DangerousInternalClient) SetSynchronousMessageNameUpdates(')
+ fi
+ for symbol in "${required_candidate_symbols[@]}"; do
+ if ! git -C "$repo_dir" grep -Fq "$symbol" "$candidate_sha" -- '*.go'; then
+ candidate_build_tags=benchmark_legacy
+ break
+ fi
+ done
+fi
repeats=${BENCH_REPEATS:-1}
repeat_start=${BENCH_REPEAT_START:-1}
if ! [[ $repeats =~ ^[1-9][0-9]*$ ]]; then
@@ -42,10 +66,21 @@ fi
run_revision() {
local name=$1 context=$2 revision=$3 repeat=$4 scenario=$5 variant=$1
+ local build_tags=
+ if [[ $name != hypermeow ]]; then
+ build_tags=benchmark_legacy
+ else
+ build_tags=$candidate_build_tags
+ fi
+ local security_code_smoke
+ security_code_smoke=$(comparison_security_code_smoke "$name" "$build_tags" "${BENCH_SECURITY_CODE_SMOKE:-false}")
if ((repeats > 1)); then
variant="${name}-r${repeat}"
fi
- LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="$variant" ./run-system-matrix.sh "$scenario"
+ if [[ $security_code_smoke == true ]]; then
+ LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="${variant}-security-smoke" BENCH_BUILD_TAGS="$build_tags" BENCH_SECURITY_CODE_SMOKE=true BENCH_SMOKE_ONLY=true MEM_PROFILE_SCENARIO= ./run-system-matrix.sh "$scenario"
+ fi
+ LIBRARY_CONTEXT="$context" BUILD_REV="$revision" BENCH_VARIANT="$variant" BENCH_BUILD_TAGS="$build_tags" BENCH_SECURITY_CODE_SMOKE=false BENCH_SMOKE_ONLY=false ./run-system-matrix.sh "$scenario"
}
for ((repeat = repeat_start; repeat <= repeats; repeat++)); do
diff --git a/binary/attrs.go b/binary/attrs.go
index 24880e567..1574ddc8c 100644
--- a/binary/attrs.go
+++ b/binary/attrs.go
@@ -11,7 +11,7 @@ import (
"strconv"
"time"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/types"
)
// AttrUtility is a helper struct for reading multiple XML attributes and checking for errors afterwards.
diff --git a/binary/decoder.go b/binary/decoder.go
index 476f87bd1..62ad421eb 100644
--- a/binary/decoder.go
+++ b/binary/decoder.go
@@ -1,12 +1,13 @@
package binary
import (
+ "errors"
"fmt"
"io"
"strings"
- "go.mau.fi/whatsmeow/binary/token"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/binary/token"
+ "github.com/polymorfa/hypermeow/types"
)
type binaryDecoder struct {
@@ -110,6 +111,9 @@ func (r *binaryDecoder) readPacked8(tag int) (string, error) {
ret := build.String()
if startByte>>7 != 0 {
+ if len(ret) == 0 {
+ return "", errors.New("packed string has odd flag without data")
+ }
ret = ret[:len(ret)-1]
}
return ret, nil
@@ -232,10 +236,19 @@ func (r *binaryDecoder) readJIDPair() (any, error) {
return nil, err
} else if server == nil {
return nil, ErrInvalidJIDType
- } else if user == nil {
- return types.NewJID("", server.(string)), nil
}
- return types.NewJID(user.(string), server.(string)), nil
+ serverStr, ok := server.(string)
+ if !ok {
+ return nil, ErrInvalidJIDType
+ }
+ if user == nil {
+ return types.NewJID("", serverStr), nil
+ }
+ userStr, ok := user.(string)
+ if !ok {
+ return nil, ErrInvalidJIDType
+ }
+ return types.NewJID(userStr, serverStr), nil
}
func (r *binaryDecoder) readInteropJID() (any, error) {
@@ -257,8 +270,12 @@ func (r *binaryDecoder) readInteropJID() (any, error) {
} else if server != types.InteropServer {
return nil, fmt.Errorf("%w: expected %q, got %q", ErrInvalidJIDType, types.InteropServer, server)
}
+ userStr, ok := user.(string)
+ if !ok {
+ return nil, ErrInvalidJIDType
+ }
return types.JID{
- User: user.(string),
+ User: userStr,
Device: uint16(device),
Integrator: uint16(integrator),
Server: types.InteropServer,
@@ -280,10 +297,14 @@ func (r *binaryDecoder) readFBJID() (any, error) {
} else if server != types.MessengerServer {
return nil, fmt.Errorf("%w: expected %q, got %q", ErrInvalidJIDType, types.MessengerServer, server)
}
+ userStr, ok := user.(string)
+ if !ok {
+ return nil, ErrInvalidJIDType
+ }
return types.JID{
- User: user.(string),
+ User: userStr,
Device: uint16(device),
- Server: server.(string),
+ Server: types.MessengerServer,
}, nil
}
@@ -300,7 +321,11 @@ func (r *binaryDecoder) readADJID() (any, error) {
if err != nil {
return nil, err
}
- return types.NewADJID(user.(string), agent, device), nil
+ userStr, ok := user.(string)
+ if !ok {
+ return nil, ErrInvalidJIDType
+ }
+ return types.NewADJID(userStr, agent, device), nil
}
func (r *binaryDecoder) readAttributes(n int) (Attrs, error) {
@@ -365,7 +390,11 @@ func (r *binaryDecoder) readNode() (*Node, error) {
if err != nil {
return nil, err
}
- ret.Tag = rawDesc.(string)
+ tag, ok := rawDesc.(string)
+ if !ok {
+ return nil, ErrInvalidNode
+ }
+ ret.Tag = tag
if listSize == 0 || ret.Tag == "" {
return nil, ErrInvalidNode
}
diff --git a/binary/decoder_test.go b/binary/decoder_test.go
new file mode 100644
index 000000000..8377e374c
--- /dev/null
+++ b/binary/decoder_test.go
@@ -0,0 +1,87 @@
+package binary_test
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
+)
+
+func TestMarshalUnmarshalRoundTrip(t *testing.T) {
+ want := binary.Node{
+ Tag: "iq",
+ Attrs: binary.Attrs{
+ "id": "abc",
+ "from": types.NewJID("12345", types.DefaultUserServer),
+ },
+ Content: []binary.Node{{Tag: "body", Content: []byte("hi")}},
+ }
+ marshaled, err := binary.Marshal(want)
+ if err != nil {
+ t.Fatal(err)
+ }
+ unpacked, err := binary.Unpack(marshaled)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := binary.Unmarshal(unpacked)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(*got, want) {
+ t.Fatalf("round trip mismatch: got %#v, want %#v", *got, want)
+ }
+}
+
+func TestUnmarshalRejectsMalformedStringTokens(t *testing.T) {
+ tests := map[string][]byte{
+ "nil node tag": {248, 1, 0},
+ "empty packed tag": {248, 1, 255, 128},
+ "empty packed attribute": {248, 3, 252, 1, 97, 255, 128, 252, 1, 120},
+ "JID user list": {248, 2, 252, 1, 97, 250, 248, 0, 252, 1, 115},
+ "JID server list": {248, 2, 252, 1, 97, 250, 0, 248, 0},
+ "AD JID user list": {248, 2, 252, 1, 97, 247, 1, 1, 248, 0},
+ "FB JID user list": {248, 2, 252, 1, 97, 246, 248, 0, 0, 0, 252, 4, 109, 115, 103, 114},
+ "interop user list": {248, 2, 252, 1, 97, 245, 248, 0, 0, 0, 0, 0, 252, 7,
+ 105, 110, 116, 101, 114, 111, 112},
+ }
+ for name, input := range tests {
+ t.Run(name, func(t *testing.T) {
+ if _, err := binary.Unmarshal(input); err == nil {
+ t.Fatal("expected malformed node error")
+ }
+ })
+ }
+}
+
+func TestUnpackRejectsEmptyInput(t *testing.T) {
+ for _, input := range [][]byte{nil, {}} {
+ if _, err := binary.Unpack(input); err == nil {
+ t.Fatal("expected empty input error")
+ }
+ }
+}
+
+func FuzzUnmarshal(f *testing.F) {
+ for _, input := range [][]byte{
+ {248, 1, 0},
+ {248, 1, 255, 128},
+ {248, 2, 252, 1, 97, 250, 248, 0, 252, 1, 115},
+ {},
+ } {
+ f.Add(input)
+ }
+ f.Fuzz(func(t *testing.T, input []byte) {
+ _, _ = binary.Unmarshal(input)
+ })
+}
+
+func FuzzUnpack(f *testing.F) {
+ for _, input := range [][]byte{{}, {0}, {1}, {2, 1, 2, 3}} {
+ f.Add(input)
+ }
+ f.Fuzz(func(t *testing.T, input []byte) {
+ _, _ = binary.Unpack(input)
+ })
+}
diff --git a/binary/encoder.go b/binary/encoder.go
index 3b2b014a3..0e35b1cfb 100644
--- a/binary/encoder.go
+++ b/binary/encoder.go
@@ -5,8 +5,8 @@ import (
"math"
"strconv"
- "go.mau.fi/whatsmeow/binary/token"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/binary/token"
+ "github.com/polymorfa/hypermeow/types"
)
type binaryEncoder struct {
diff --git a/binary/node.go b/binary/node.go
index b421f8a59..0697d9b0c 100644
--- a/binary/node.go
+++ b/binary/node.go
@@ -11,7 +11,7 @@ import (
"encoding/json"
"fmt"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/types"
)
// Attrs is a type alias for the attributes of an XML element (Node).
diff --git a/binary/proto/doc.go b/binary/proto/doc.go
index 6ebecb93f..9f6396063 100644
--- a/binary/proto/doc.go
+++ b/binary/proto/doc.go
@@ -1,4 +1,4 @@
// Package proto contains type aliases for backwards compatibility.
//
-// Deprecated: New code should reference the protobuf types in the go.mau.fi/whatsmeow/proto/wa* packages directly.
+// Deprecated: New code should reference the protobuf types in the github.com/polymorfa/hypermeow/proto/wa* packages directly.
package proto
diff --git a/binary/proto/legacy.go b/binary/proto/legacy.go
index cf9177eeb..5b0fc1a1a 100644
--- a/binary/proto/legacy.go
+++ b/binary/proto/legacy.go
@@ -3,26 +3,26 @@
package proto
import (
- "go.mau.fi/whatsmeow/proto/waAICommon"
- "go.mau.fi/whatsmeow/proto/waAdv"
- "go.mau.fi/whatsmeow/proto/waBotMetadata"
- "go.mau.fi/whatsmeow/proto/waCert"
- "go.mau.fi/whatsmeow/proto/waChatLockSettings"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waCompanionReg"
- "go.mau.fi/whatsmeow/proto/waDeviceCapabilities"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/proto/waEphemeral"
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/proto/waMmsRetry"
- "go.mau.fi/whatsmeow/proto/waMsgTransport"
- "go.mau.fi/whatsmeow/proto/waQuickPromotionSurfaces"
- "go.mau.fi/whatsmeow/proto/waServerSync"
- "go.mau.fi/whatsmeow/proto/waSyncAction"
- "go.mau.fi/whatsmeow/proto/waUserPassword"
- "go.mau.fi/whatsmeow/proto/waVnameCert"
- "go.mau.fi/whatsmeow/proto/waWa6"
- "go.mau.fi/whatsmeow/proto/waWeb"
+ "github.com/polymorfa/hypermeow/proto/waAICommon"
+ "github.com/polymorfa/hypermeow/proto/waAdv"
+ "github.com/polymorfa/hypermeow/proto/waBotMetadata"
+ "github.com/polymorfa/hypermeow/proto/waCert"
+ "github.com/polymorfa/hypermeow/proto/waChatLockSettings"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waCompanionReg"
+ "github.com/polymorfa/hypermeow/proto/waDeviceCapabilities"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/proto/waEphemeral"
+ "github.com/polymorfa/hypermeow/proto/waHistorySync"
+ "github.com/polymorfa/hypermeow/proto/waMmsRetry"
+ "github.com/polymorfa/hypermeow/proto/waMsgTransport"
+ "github.com/polymorfa/hypermeow/proto/waQuickPromotionSurfaces"
+ "github.com/polymorfa/hypermeow/proto/waServerSync"
+ "github.com/polymorfa/hypermeow/proto/waSyncAction"
+ "github.com/polymorfa/hypermeow/proto/waUserPassword"
+ "github.com/polymorfa/hypermeow/proto/waVnameCert"
+ "github.com/polymorfa/hypermeow/proto/waWa6"
+ "github.com/polymorfa/hypermeow/proto/waWeb"
)
// Deprecated: use new packages directly
diff --git a/binary/unpack.go b/binary/unpack.go
index a4557c57a..3e4c14a68 100644
--- a/binary/unpack.go
+++ b/binary/unpack.go
@@ -19,6 +19,9 @@ import (
// (without the first byte). There's currently no corresponding Pack function because Marshal
// already returns the data with a leading zero (i.e. not compressed).
func Unpack(data []byte) ([]byte, error) {
+ if len(data) == 0 {
+ return nil, io.ErrUnexpectedEOF
+ }
dataType, data := data[0], data[1:]
if 2&dataType > 0 {
if decompressor, err := zlib.NewReader(bytes.NewReader(data)); err != nil {
diff --git a/binary/xml.go b/binary/xml.go
index 486afd912..946bae222 100644
--- a/binary/xml.go
+++ b/binary/xml.go
@@ -21,6 +21,19 @@ var (
MaxBytesToPrintAsHex = 128
)
+func sensitiveXMLAttribute(key string) bool {
+ return key == "auth" || key == "pin" || key == "token"
+}
+
+func sensitiveXMLNodeContent(tag string) bool {
+ switch tag {
+ case "access_token", "address", "code", "description", "email", "linked_accounts", "session_cookies", "token", "wa_ad_account_nonce", "website":
+ return true
+ default:
+ return false
+ }
+}
+
// String converts the Node to its XML representation
func (n Node) String() string {
content := n.contentString()
@@ -41,6 +54,9 @@ func (n *Node) attributeString() string {
stringAttrs := make([]string, len(n.Attrs)+1)
i := 1
for key, value := range n.Attrs {
+ if sensitiveXMLAttribute(key) {
+ value = "[redacted]"
+ }
stringAttrs[i] = fmt.Sprintf(`%s="%v"`, key, value)
i++
}
@@ -63,6 +79,9 @@ func printable(data []byte) string {
func (n *Node) contentString() []string {
split := make([]string, 0)
+ if n.Content != nil && sensitiveXMLNodeContent(n.Tag) {
+ return append(split, "[redacted]")
+ }
switch content := n.Content.(type) {
case []Node:
for _, item := range content {
diff --git a/binary/xml_test.go b/binary/xml_test.go
new file mode 100644
index 000000000..e8e63504c
--- /dev/null
+++ b/binary/xml_test.go
@@ -0,0 +1,72 @@
+package binary
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestNodeStringRedactsSensitiveContent(t *testing.T) {
+ for _, tag := range []string{
+ "access_token",
+ "address",
+ "code",
+ "description",
+ "email",
+ "session_cookies",
+ "token",
+ "wa_ad_account_nonce",
+ "website",
+ } {
+ for _, contentType := range []struct {
+ name string
+ value func(string) any
+ }{
+ {name: "bytes", value: func(secret string) any { return []byte(secret) }},
+ {name: "string", value: func(secret string) any { return secret }},
+ } {
+ t.Run(tag+"/"+contentType.name, func(t *testing.T) {
+ secret := "private-" + tag
+ logged := (Node{Tag: tag, Content: contentType.value(secret)}).String()
+ if strings.Contains(logged, secret) || !strings.Contains(logged, "[redacted]") {
+ t.Fatalf("sensitive content was not redacted: %s", logged)
+ }
+ })
+ }
+ }
+}
+
+func TestNodeStringRedactsSensitiveAttributes(t *testing.T) {
+ logged := (Node{
+ Tag: "cover_photo",
+ Attrs: Attrs{"auth": "media-secret", "pin": "1234", "token": "upload-secret", "id": "cover-100"},
+ }).String()
+ if strings.Contains(logged, "media-secret") || strings.Contains(logged, "1234") || strings.Contains(logged, "upload-secret") {
+ t.Fatalf("sensitive attributes were not redacted: %s", logged)
+ }
+ if !strings.Contains(logged, `id="cover-100"`) || strings.Count(logged, "[redacted]") != 3 {
+ t.Fatalf("unexpected redacted node: %s", logged)
+ }
+}
+
+func TestNodeStringRedactsLinkedAccountPayload(t *testing.T) {
+ logged := (Node{
+ Tag: "linked_accounts",
+ Content: []Node{{
+ Tag: "fb_page",
+ Attrs: Attrs{"id": "facebook-page-100"},
+ Content: []Node{
+ {Tag: "display_name", Content: []byte("Private Store")},
+ {Tag: "ig_handle", Content: "private_handle"},
+ {Tag: "profile_picture", Content: []Node{{Tag: "url", Content: []byte("https://private.invalid/picture?access=secret")}}},
+ },
+ }},
+ }).String()
+ for _, secret := range []string{"facebook-page-100", "Private Store", "private_handle", "private.invalid", "secret"} {
+ if strings.Contains(logged, secret) {
+ t.Fatalf("linked-account payload leaked %q: %s", secret, logged)
+ }
+ }
+ if logged != "[redacted]" {
+ t.Fatalf("unexpected linked-account redaction: %s", logged)
+ }
+}
diff --git a/broadcast.go b/broadcast.go
index d3768297d..1855f97c2 100644
--- a/broadcast.go
+++ b/broadcast.go
@@ -11,8 +11,8 @@ import (
"errors"
"fmt"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
)
func (cli *Client) getBroadcastListParticipants(ctx context.Context, jid types.JID) ([]types.JID, error) {
diff --git a/business.go b/business.go
index 6291b944f..ce8b18e72 100644
--- a/business.go
+++ b/business.go
@@ -10,14 +10,18 @@ import (
"context"
"fmt"
"strconv"
+ "strings"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
)
// GetOrderDetails fetches the details of a specific order using its ID and token.
// Both token and orderID are found in the OrderMessage.
func (cli *Client) GetOrderDetails(ctx context.Context, orderID, tokenBase64 string) (*types.OrderDetails, error) {
+ if err := validateOrderLookup(orderID, tokenBase64); err != nil {
+ return nil, err
+ }
resp, err := cli.sendIQ(ctx, infoQuery{
Namespace: "fb:thrift_iq",
Type: iqGet,
@@ -50,7 +54,37 @@ func (cli *Client) GetOrderDetails(ctx context.Context, orderID, tokenBase64 str
return nil, &ElementMissingError{Tag: "order", In: "response to order query"}
}
- return parseOrderDetailsNode(orderNode)
+ details, err := parseOrderDetailsNode(orderNode)
+ if err != nil {
+ return nil, err
+ }
+ if err = validateOrderResponseID(orderID, details.ID); err != nil {
+ return nil, err
+ }
+ return details, nil
+}
+
+func validateOrderLookup(orderID, token string) error {
+ if strings.TrimSpace(orderID) == "" {
+ return fmt.Errorf("order ID is empty")
+ }
+ if len(orderID) > 256 {
+ return fmt.Errorf("order ID exceeds 256 bytes")
+ }
+ if strings.TrimSpace(token) == "" {
+ return fmt.Errorf("order token is empty")
+ }
+ if len(token) > 8192 {
+ return fmt.Errorf("order token exceeds 8192 bytes")
+ }
+ return nil
+}
+
+func validateOrderResponseID(requested, returned string) error {
+ if returned != requested {
+ return fmt.Errorf("order response ID %q does not match requested ID %q", returned, requested)
+ }
+ return nil
}
// Helper to get the string content of a child node.
@@ -73,11 +107,16 @@ func parseOrderDetailsNode(orderNode waBinary.Node) (*types.OrderDetails, error)
return nil, err
}
- // Parse Price
priceNode, ok := orderNode.GetOptionalChildByTag("price")
if ok {
- subtotal, _ := strconv.ParseInt(getStringChild(priceNode, "subtotal"), 10, 64)
- total, _ := strconv.ParseInt(getStringChild(priceNode, "total"), 10, 64)
+ subtotal, err := parseInt64Child(priceNode, "subtotal")
+ if err != nil {
+ return nil, err
+ }
+ total, err := parseInt64Child(priceNode, "total")
+ if err != nil {
+ return nil, err
+ }
details.Price = types.OrderPrice{
Subtotal: subtotal,
Total: total,
@@ -86,16 +125,20 @@ func parseOrderDetailsNode(orderNode waBinary.Node) (*types.OrderDetails, error)
}
}
- // Parse Catalog ID
catalogNode, ok := orderNode.GetOptionalChildByTag("catalog")
if ok {
details.CatalogID = getStringChild(catalogNode, "id")
}
- // Parse Products
for _, productNode := range orderNode.GetChildrenByTag("product") {
- price, _ := strconv.ParseInt(getStringChild(productNode, "price"), 10, 64)
- quantity, _ := strconv.Atoi(getStringChild(productNode, "quantity"))
+ price, err := parseInt64Child(productNode, "price")
+ if err != nil {
+ return nil, err
+ }
+ quantity, err := parseIntChild(productNode, "quantity")
+ if err != nil {
+ return nil, err
+ }
product := types.OrderProduct{
ID: getStringChild(productNode, "id"),
@@ -105,13 +148,11 @@ func parseOrderDetailsNode(orderNode waBinary.Node) (*types.OrderDetails, error)
Quantity: quantity,
}
- // Parse Product Image
if imageNode, ok := productNode.GetOptionalChildByTag("image"); ok {
product.ImageID = getStringChild(imageNode, "id")
product.ImageURL = getStringChild(imageNode, "url")
}
- // Parse Variant Info
if variantNode, ok := productNode.GetOptionalChildByTag("variant_info"); ok {
product.VariantInfo.Properties = getStringChild(variantNode, "properties")
}
@@ -121,3 +162,21 @@ func parseOrderDetailsNode(orderNode waBinary.Node) (*types.OrderDetails, error)
return details, nil
}
+
+func parseInt64Child(node waBinary.Node, tag string) (int64, error) {
+ raw := getStringChild(node, tag)
+ value, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid %s value %q: %w", tag, raw, err)
+ }
+ return value, nil
+}
+
+func parseIntChild(node waBinary.Node, tag string) (int, error) {
+ raw := getStringChild(node, tag)
+ value, err := strconv.Atoi(raw)
+ if err != nil {
+ return 0, fmt.Errorf("invalid %s value %q: %w", tag, raw, err)
+ }
+ return value, nil
+}
diff --git a/business_account.go b/business_account.go
new file mode 100644
index 000000000..a841311db
--- /dev/null
+++ b/business_account.go
@@ -0,0 +1,343 @@
+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
new file mode 100644
index 000000000..ec77a62a4
--- /dev/null
+++ b/business_account_test.go
@@ -0,0 +1,135 @@
+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
new file mode 100644
index 000000000..8b031299a
--- /dev/null
+++ b/business_catalog.go
@@ -0,0 +1,427 @@
+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
new file mode 100644
index 000000000..d0087b4aa
--- /dev/null
+++ b/business_catalog_test.go
@@ -0,0 +1,201 @@
+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
new file mode 100644
index 000000000..c5cb16533
--- /dev/null
+++ b/business_collection_mutation.go
@@ -0,0 +1,292 @@
+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
new file mode 100644
index 000000000..3de02d5de
--- /dev/null
+++ b/business_collection_mutation_test.go
@@ -0,0 +1,106 @@
+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
new file mode 100644
index 000000000..832e8a5fa
--- /dev/null
+++ b/business_commerce_control.go
@@ -0,0 +1,192 @@
+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
new file mode 100644
index 000000000..c8dfc4fda
--- /dev/null
+++ b/business_commerce_control_test.go
@@ -0,0 +1,89 @@
+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
new file mode 100644
index 000000000..e93de3594
--- /dev/null
+++ b/business_merchant_compliance.go
@@ -0,0 +1,163 @@
+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
new file mode 100644
index 000000000..eef660783
--- /dev/null
+++ b/business_merchant_compliance_test.go
@@ -0,0 +1,168 @@
+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
new file mode 100644
index 000000000..723b99342
--- /dev/null
+++ b/business_message_builders.go
@@ -0,0 +1,418 @@
+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
new file mode 100644
index 000000000..3398d4336
--- /dev/null
+++ b/business_message_builders_test.go
@@ -0,0 +1,553 @@
+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
new file mode 100644
index 000000000..1b25b1f96
--- /dev/null
+++ b/business_product_mutation.go
@@ -0,0 +1,773 @@
+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
new file mode 100644
index 000000000..572133405
--- /dev/null
+++ b/business_product_mutation_test.go
@@ -0,0 +1,436 @@
+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
new file mode 100644
index 000000000..2f2fdd593
--- /dev/null
+++ b/business_profile.go
@@ -0,0 +1,302 @@
+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
new file mode 100644
index 000000000..0c0dcf508
--- /dev/null
+++ b/business_profile_test.go
@@ -0,0 +1,247 @@
+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/call.go b/call.go
index 47b0e2191..485490da4 100644
--- a/call.go
+++ b/call.go
@@ -9,9 +9,9 @@ package whatsmeow
import (
"context"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
func (cli *Client) handleCallEvent(ctx context.Context, node *waBinary.Node) {
diff --git a/client.go b/client.go
index f8f13f737..f809407df 100644
--- a/client.go
+++ b/client.go
@@ -28,17 +28,17 @@ import (
"golang.org/x/net/proxy"
"golang.org/x/sync/semaphore"
- "go.mau.fi/whatsmeow/appstate"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/proto/waWa6"
- "go.mau.fi/whatsmeow/proto/waWeb"
- "go.mau.fi/whatsmeow/socket"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "go.mau.fi/whatsmeow/util/keys"
- waLog "go.mau.fi/whatsmeow/util/log"
+ "github.com/polymorfa/hypermeow/appstate"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/proto/waWa6"
+ "github.com/polymorfa/hypermeow/proto/waWeb"
+ "github.com/polymorfa/hypermeow/socket"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
+ "github.com/polymorfa/hypermeow/util/keys"
+ waLog "github.com/polymorfa/hypermeow/util/log"
)
// EventHandler is a function that can handle events from WhatsApp.
@@ -114,9 +114,10 @@ type Client struct {
// the client will not attempt to reconnect. The number of retries can be read from AutoReconnectErrors.
AutoReconnectHook func(error) bool
// If SynchronousAck is set, acks for messages will only be sent after all event handlers return.
- SynchronousAck bool
- EnableDecryptedEventBuffer bool
- lastDecryptedBufferClear time.Time
+ SynchronousAck bool
+ EnableDecryptedEventBuffer bool
+ synchronousMessageNameUpdates atomic.Bool
+ lastDecryptedBufferClear time.Time
DisableLoginAutoReconnect bool
@@ -124,8 +125,10 @@ type Client struct {
// EmitAppStateEventsOnFullSync can be set to true if you want to get app state events emitted
// even when re-syncing the whole state.
- EmitAppStateEventsOnFullSync bool
- AppStateDebugLogs bool
+ EmitAppStateEventsOnFullSync bool
+ EmitLabelEventsOnFullSync bool
+ EmitQuickReplyEventsOnFullSync bool
+ AppStateDebugLogs bool
AutomaticMessageRerequestFromPhone bool
pendingPhoneRerequests map[types.MessageID]context.CancelFunc
@@ -134,10 +137,15 @@ type Client struct {
appStateProc *appstate.Processor
appStateSyncLock sync.Mutex
- historySyncNotifications chan *waE2E.HistorySyncNotification
+ historySyncNotifications chan historySyncNotification
historySyncHandlerStarted atomic.Bool
ManualHistorySyncDownload bool
DisableManualHistorySyncReceipt bool
+ DisableHistorySyncReceipt bool
+ DisableHistorySyncStorage bool
+ DisableHistorySyncMediaDelete bool
+ historySyncNonce atomic.Pointer[string]
+ historySyncNonceSaveLock sync.Mutex
uploadPreKeysLock sync.Mutex
lastPreKeyUpload time.Time
@@ -147,6 +155,7 @@ type Client struct {
responseWaiters map[string]chan<- *waBinary.Node
responseWaitersLock sync.Mutex
+ businessCatalogAuth atomic.Pointer[businessCatalogAuthState]
handlerQueue chan *waBinary.Node
eventHandlers []wrappedEventHandler
@@ -323,7 +332,7 @@ func NewClient(deviceStore *store.Device, log waLog.Logger) *Client {
socketWait: make(chan struct{}),
expectedDisconnect: exsync.NewEvent(),
- historySyncNotifications: make(chan *waE2E.HistorySyncNotification, 32),
+ historySyncNotifications: make(chan historySyncNotification, 32),
GetMessageForRetry: func(requester, to types.JID, id types.MessageID) *waE2E.Message { return nil },
@@ -896,6 +905,7 @@ func (cli *Client) handleFrame(ctx context.Context, data []byte) {
}
}
cli.recvLog.Debugf("%s", node)
+ cli.handleOutOfBandNode(node)
// Signal-disabled handoff: parse and dispatch UndecryptedMessage
// synchronously from the recv goroutine, so the event interleaves
// with [RawNodeHandler] callbacks in wire order. Going through the
@@ -928,6 +938,13 @@ func (cli *Client) handleFrame(ctx context.Context, data []byte) {
}
}
+func (cli *Client) handleOutOfBandNode(node *waBinary.Node) {
+ if node.Tag == "notification" && node.Attrs["type"] == "business" {
+ cli.handleBusinessCatalogNotification(node)
+ node.Attrs[businessNonceDeliveredAttr] = true
+ }
+}
+
func (cli *Client) enqueueNode(ctx context.Context, node *waBinary.Node) {
select {
case cli.handlerQueue <- node:
diff --git a/client_memory_test.go b/client_memory_test.go
index 6d35d79e9..9261c460b 100644
--- a/client_memory_test.go
+++ b/client_memory_test.go
@@ -6,9 +6,9 @@ import (
"runtime"
"testing"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/store"
- waLog "go.mau.fi/whatsmeow/util/log"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/store"
+ waLog "github.com/polymorfa/hypermeow/util/log"
)
func TestNewClientDefersSparseState(t *testing.T) {
diff --git a/client_test.go b/client_test.go
index 895649a7a..c663989c0 100644
--- a/client_test.go
+++ b/client_test.go
@@ -13,10 +13,10 @@ import (
"os/signal"
"syscall"
- "go.mau.fi/whatsmeow"
- "go.mau.fi/whatsmeow/store/sqlstore"
- "go.mau.fi/whatsmeow/types/events"
- waLog "go.mau.fi/whatsmeow/util/log"
+ whatsmeow "github.com/polymorfa/hypermeow"
+ "github.com/polymorfa/hypermeow/store/sqlstore"
+ "github.com/polymorfa/hypermeow/types/events"
+ waLog "github.com/polymorfa/hypermeow/util/log"
)
func eventHandler(evt any) {
diff --git a/connectionevents.go b/connectionevents.go
index 47ab0fb2f..3e52853d3 100644
--- a/connectionevents.go
+++ b/connectionevents.go
@@ -10,10 +10,10 @@ import (
"context"
"time"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
func (cli *Client) handleStreamError(ctx context.Context, node *waBinary.Node) {
diff --git a/cstoken.go b/cstoken.go
index b69e849b7..aebb172b7 100644
--- a/cstoken.go
+++ b/cstoken.go
@@ -11,7 +11,7 @@ import (
"crypto/hmac"
"crypto/sha256"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/types"
)
func shouldSendCsToken(jid types.JID) bool {
diff --git a/download-to-file.go b/download-to-file.go
index 7518695a2..acbeb22e4 100644
--- a/download-to-file.go
+++ b/download-to-file.go
@@ -21,8 +21,8 @@ import (
"go.mau.fi/util/fallocate"
"go.mau.fi/util/retryafter"
- "go.mau.fi/whatsmeow/proto/waMediaTransport"
- "go.mau.fi/whatsmeow/util/cbcutil"
+ "github.com/polymorfa/hypermeow/proto/waMediaTransport"
+ "github.com/polymorfa/hypermeow/util/cbcutil"
)
type File interface {
diff --git a/download.go b/download.go
index 9ae485864..7058ccb09 100644
--- a/download.go
+++ b/download.go
@@ -24,14 +24,14 @@ import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/proto/waMediaTransport"
- "go.mau.fi/whatsmeow/proto/waServerSync"
- "go.mau.fi/whatsmeow/socket"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/util/cbcutil"
- "go.mau.fi/whatsmeow/util/hkdfutil"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/proto/waHistorySync"
+ "github.com/polymorfa/hypermeow/proto/waMediaTransport"
+ "github.com/polymorfa/hypermeow/proto/waServerSync"
+ "github.com/polymorfa/hypermeow/socket"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/util/cbcutil"
+ "github.com/polymorfa/hypermeow/util/hkdfutil"
)
// MediaType represents a type of uploaded file on WhatsApp.
diff --git a/errors.go b/errors.go
index 72e94b6e6..fbee5025b 100644
--- a/errors.go
+++ b/errors.go
@@ -10,8 +10,10 @@ import (
"errors"
"fmt"
"net/http"
+ "reflect"
+ "strconv"
- waBinary "go.mau.fi/whatsmeow/binary"
+ waBinary "github.com/polymorfa/hypermeow/binary"
)
// Miscellaneous errors
@@ -231,12 +233,70 @@ func (iqe *IQError) Is(other error) bool {
} else if iqe.Code != 0 && otherIQE.Code != 0 {
return otherIQE.Code == iqe.Code && otherIQE.Text == iqe.Text
} else if iqe.ErrorNode != nil && otherIQE.ErrorNode != nil {
- return iqe.ErrorNode.String() == otherIQE.ErrorNode.String()
+ return reflect.DeepEqual(normalizeIQErrorNode(iqe.ErrorNode), normalizeIQErrorNode(otherIQE.ErrorNode))
} else {
return false
}
}
+func normalizeIQErrorNode(node *waBinary.Node) *waBinary.Node {
+ if node == nil {
+ return nil
+ }
+ normalized := *node
+ if len(node.Attrs) > 0 {
+ normalized.Attrs = make(waBinary.Attrs, len(node.Attrs))
+ for key, value := range node.Attrs {
+ value = normalizeIQErrorScalar(value)
+ if value != nil && value != "" {
+ normalized.Attrs[key] = value
+ }
+ }
+ if len(normalized.Attrs) == 0 {
+ normalized.Attrs = nil
+ }
+ } else {
+ normalized.Attrs = nil
+ }
+ if children, ok := node.Content.([]waBinary.Node); ok {
+ if len(children) == 0 {
+ normalized.Content = nil
+ } else {
+ normalizedChildren := make([]waBinary.Node, len(children))
+ for index := range children {
+ normalizedChildren[index] = *normalizeIQErrorNode(&children[index])
+ }
+ normalized.Content = normalizedChildren
+ }
+ } else {
+ normalized.Content = normalizeIQErrorScalar(node.Content)
+ }
+ return &normalized
+}
+
+func normalizeIQErrorScalar(value any) any {
+ switch typedValue := value.(type) {
+ case int:
+ return strconv.Itoa(typedValue)
+ case int32:
+ return strconv.FormatInt(int64(typedValue), 10)
+ case int64:
+ return strconv.FormatInt(typedValue, 10)
+ case uint:
+ return strconv.FormatUint(uint64(typedValue), 10)
+ case uint32:
+ return strconv.FormatUint(uint64(typedValue), 10)
+ case uint64:
+ return strconv.FormatUint(typedValue, 10)
+ case bool:
+ return strconv.FormatBool(typedValue)
+ case []byte:
+ return string(typedValue)
+ default:
+ return value
+ }
+}
+
// ElementMissingError is returned by various functions that parse XML elements when a required element is missing.
type ElementMissingError struct {
Tag string
diff --git a/errors_iq_test.go b/errors_iq_test.go
new file mode 100644
index 000000000..4cbc77d07
--- /dev/null
+++ b/errors_iq_test.go
@@ -0,0 +1,91 @@
+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 2661c2d4f..6478839ec 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module go.mau.fi/whatsmeow
+module github.com/polymorfa/hypermeow
go 1.25.0
diff --git a/group.go b/group.go
index 816729263..b0ee505b9 100644
--- a/group.go
+++ b/group.go
@@ -13,10 +13,10 @@ import (
"fmt"
"strings"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
const InviteLinkPrefix = "https://chat.whatsapp.com/"
@@ -157,7 +157,7 @@ func (cli *Client) CreateGroup(ctx context.Context, req ReqCreateGroup) (*types.
if !ok {
return nil, &ElementMissingError{Tag: "group", In: "response to create group query"}
}
- return cli.parseGroupNode(&groupNode)
+ return cli.parseGroupNodeAndStoreUsernames(ctx, &groupNode)
}
// UnlinkGroup removes a child group from a parent community.
@@ -256,6 +256,7 @@ func (cli *Client) UpdateGroupParticipants(ctx context.Context, jid types.JID, p
for i, child := range requestParticipants {
participants[i] = parseParticipant(child.AttrGetter(), &child)
}
+ cli.storeContactUsernamesBestEffort(ctx, groupParticipantUsernames(participants))
return participants, nil
}
@@ -272,14 +273,24 @@ func (cli *Client) GetGroupRequestParticipants(ctx context.Context, jid types.JI
return nil, &ElementMissingError{Tag: "membership_approval_requests", In: "response to group request participants query"}
}
requestParticipants := request.GetChildrenByTag("membership_approval_request")
- participants := make([]types.GroupParticipantRequest, len(requestParticipants))
- for i, req := range requestParticipants {
+ participants, usernames := parseGroupParticipantRequests(requestParticipants)
+ cli.storeContactUsernamesBestEffort(ctx, usernames)
+ return participants, nil
+}
+
+func parseGroupParticipantRequests(nodes []waBinary.Node) ([]types.GroupParticipantRequest, []store.ContactUsernameEntry) {
+ participants := make([]types.GroupParticipantRequest, len(nodes))
+ usernames := make([]store.ContactUsernameEntry, 0, len(nodes))
+ for i := range nodes {
+ ag := nodes[i].AttrGetter()
+ participant := parseParticipant(ag, &nodes[i])
participants[i] = types.GroupParticipantRequest{
- JID: req.AttrGetter().JID("jid"),
- RequestedAt: req.AttrGetter().UnixTime("request_time"),
+ JID: participant.JID,
+ RequestedAt: ag.UnixTime("request_time"),
}
+ usernames = append(usernames, groupParticipantUsernames([]types.GroupParticipant{participant})...)
}
- return participants, nil
+ return participants, usernames
}
type ParticipantRequestChange string
@@ -321,6 +332,7 @@ func (cli *Client) UpdateGroupRequestParticipants(ctx context.Context, jid types
for i, child := range requestParticipants {
participants[i] = parseParticipant(child.AttrGetter(), &child)
}
+ cli.storeContactUsernamesBestEffort(ctx, groupParticipantUsernames(participants))
return participants, nil
}
@@ -472,7 +484,7 @@ func (cli *Client) GetGroupInfoFromInvite(ctx context.Context, jid, inviter type
if !ok {
return nil, &ElementMissingError{Tag: "group", In: "response to invite group info query"}
}
- return cli.parseGroupNode(&groupNode)
+ return cli.parseGroupNodeAndStoreUsernames(ctx, &groupNode)
}
// JoinGroupWithInvite joins a group using an invite message.
@@ -510,7 +522,7 @@ func (cli *Client) GetGroupInfoFromLink(ctx context.Context, code string) (*type
if !ok {
return nil, &ElementMissingError{Tag: "group", In: "response to group link info query"}
}
- return cli.parseGroupNode(&groupNode)
+ return cli.parseGroupNodeAndStoreUsernames(ctx, &groupNode)
}
// JoinGroupWithLink joins the group using the given invite link.
@@ -559,6 +571,7 @@ func (cli *Client) GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, err
infos := make([]*types.GroupInfo, 0, len(children))
var allLIDPairs []store.LIDMapping
var allRedactedPhones []store.RedactedPhoneEntry
+ var allUsernames []store.ContactUsernameEntry
for _, child := range children {
if child.Tag != "group" {
cli.Log.Debugf("Unexpected child in group list response: %s", &child)
@@ -571,6 +584,7 @@ func (cli *Client) GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, err
lidPairs, redactedPhones := cli.cacheGroupInfo(parsed, true)
allLIDPairs = append(allLIDPairs, lidPairs...)
allRedactedPhones = append(allRedactedPhones, redactedPhones...)
+ allUsernames = append(allUsernames, groupContactUsernames(parsed)...)
infos = append(infos, parsed)
}
err = cli.Store.LIDs.PutManyLIDMappings(ctx, allLIDPairs)
@@ -581,6 +595,9 @@ func (cli *Client) GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, err
if err != nil {
cli.Log.Warnf("Failed to store redacted phones from joined groups: %v", err)
}
+ if err = putContactUsernames(ctx, cli.Store.Contacts, allUsernames); err != nil {
+ cli.Log.Warnf("Failed to store usernames from joined groups: %v", err)
+ }
return infos, nil
}
@@ -617,13 +634,14 @@ func (cli *Client) GetLinkedGroupsParticipants(ctx context.Context, community ty
if !ok {
return nil, &ElementMissingError{Tag: "linked_groups_participants", In: "response to community participants query"}
}
- members, lidPairs := parseParticipantList(&participants)
+ members, lidPairs, usernames := parseParticipantList(&participants)
if len(lidPairs) > 0 {
err = cli.Store.LIDs.PutManyLIDMappings(ctx, lidPairs)
if err != nil {
cli.Log.Warnf("Failed to store LID mappings for community participants: %v", err)
}
}
+ cli.storeContactUsernamesBestEffort(ctx, usernames)
return members, nil
}
@@ -663,6 +681,36 @@ func (cli *Client) cacheGroupInfo(groupInfo *types.GroupInfo, lock bool) ([]stor
return lidPairs, redactedPhones
}
+func groupContactUsernames(groupInfo *types.GroupInfo) []store.ContactUsernameEntry {
+ return groupParticipantUsernames(groupInfo.Participants)
+}
+
+func (cli *Client) parseGroupNodeAndStoreUsernames(ctx context.Context, groupNode *waBinary.Node) (*types.GroupInfo, error) {
+ groupInfo, err := cli.parseGroupNode(groupNode)
+ if err != nil {
+ return groupInfo, err
+ }
+ cli.storeContactUsernamesBestEffort(ctx, groupContactUsernames(groupInfo))
+ return groupInfo, nil
+}
+
+func groupParticipantUsernames(participants []types.GroupParticipant) []store.ContactUsernameEntry {
+ entries := make([]store.ContactUsernameEntry, 0, len(participants))
+ for _, participant := range participants {
+ if participant.Username == "" {
+ continue
+ }
+ lid := participant.LID.ToNonAD()
+ if lid.IsEmpty() && participant.JID.Server == types.HiddenUserServer {
+ lid = participant.JID.ToNonAD()
+ }
+ if !lid.IsEmpty() {
+ entries = append(entries, store.ContactUsernameEntry{JID: lid, Username: participant.Username})
+ }
+ }
+ return entries
+}
+
func (cli *Client) getGroupInfo(ctx context.Context, jid types.JID, lockParticipantCache bool) (*types.GroupInfo, error) {
res, err := cli.sendGroupIQ(ctx, iqGet, jid, waBinary.Node{
Tag: "query",
@@ -693,6 +741,9 @@ func (cli *Client) getGroupInfo(ctx context.Context, jid types.JID, lockParticip
if err != nil {
cli.Log.Warnf("Failed to store redacted phones for members of %s: %v", jid, err)
}
+ if err = putContactUsernames(ctx, cli.Store.Contacts, groupContactUsernames(groupInfo)); err != nil {
+ cli.Log.Warnf("Failed to store usernames for members of %s: %v", jid, err)
+ }
return groupInfo, nil
}
@@ -716,6 +767,7 @@ func parseParticipant(childAG *waBinary.AttrUtility, child *waBinary.Node) types
IsSuperAdmin: pcpType == "superadmin",
JID: childAG.JID("jid"),
DisplayName: childAG.OptionalString("display_name"),
+ Username: childAG.OptionalString("username"),
}
if participant.JID.Server == types.HiddenUserServer {
participant.LID = participant.JID
@@ -826,7 +878,7 @@ func parseGroupLinkTargetNode(groupNode *waBinary.Node) (types.GroupLinkTarget,
}, ag.Error()
}
-func parseParticipantList(node *waBinary.Node) (participants []types.JID, lidPairs []store.LIDMapping) {
+func parseParticipantList(node *waBinary.Node) (participants []types.JID, lidPairs []store.LIDMapping, usernames []store.ContactUsernameEntry) {
children := node.GetChildren()
participants = make([]types.JID, 0, len(children))
for _, child := range children {
@@ -835,6 +887,8 @@ func parseParticipantList(node *waBinary.Node) (participants []types.JID, lidPai
continue
}
participants = append(participants, jid)
+ participant := parseParticipant(child.AttrGetter(), &child)
+ usernames = append(usernames, groupParticipantUsernames([]types.GroupParticipant{participant})...)
if jid.Server == types.HiddenUserServer {
phoneNumber, ok := child.Attrs["phone_number"].(types.JID)
if ok && !phoneNumber.IsEmpty() {
@@ -882,7 +936,7 @@ func (cli *Client) parseGroupCreate(parentNode, node *waBinary.Node) (*events.Jo
return &evt, lidPairs, redactedPhones, nil
}
-func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []store.LIDMapping, error) {
+func (cli *Client) parseGroupChangeWithUsernames(node *waBinary.Node) (*events.GroupInfo, []store.LIDMapping, []store.ContactUsernameEntry, error) {
var evt events.GroupInfo
ag := node.AttrGetter()
evt.JID = ag.JID("from")
@@ -891,10 +945,11 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s
evt.SenderPN = ag.OptionalJID("participant_pn")
evt.Timestamp = ag.UnixTime("t")
if !ag.OK() {
- return nil, nil, fmt.Errorf("group change doesn't contain required attributes: %w", ag.Error())
+ return nil, nil, nil, fmt.Errorf("group change doesn't contain required attributes: %w", ag.Error())
}
var lidPairs []store.LIDMapping
+ var usernames []store.ContactUsernameEntry
for _, child := range node.GetChildren() {
cag := child.AttrGetter()
if child.Tag == "add" || child.Tag == "remove" || child.Tag == "promote" || child.Tag == "demote" {
@@ -904,13 +959,25 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s
switch child.Tag {
case "add":
evt.JoinReason = cag.OptionalString("reason")
- evt.Join, lidPairs = parseParticipantList(&child)
+ participants, pairs, names := parseParticipantList(&child)
+ evt.Join = participants
+ lidPairs = append(lidPairs, pairs...)
+ usernames = append(usernames, names...)
case "remove":
- evt.Leave, lidPairs = parseParticipantList(&child)
+ participants, pairs, names := parseParticipantList(&child)
+ evt.Leave = participants
+ lidPairs = append(lidPairs, pairs...)
+ usernames = append(usernames, names...)
case "promote":
- evt.Promote, lidPairs = parseParticipantList(&child)
+ participants, pairs, names := parseParticipantList(&child)
+ evt.Promote = participants
+ lidPairs = append(lidPairs, pairs...)
+ usernames = append(usernames, names...)
case "demote":
- evt.Demote, lidPairs = parseParticipantList(&child)
+ participants, pairs, names := parseParticipantList(&child)
+ evt.Demote = participants
+ lidPairs = append(lidPairs, pairs...)
+ usernames = append(usernames, names...)
case "locked":
evt.Locked = &types.GroupLocked{IsLocked: true}
case "unlocked":
@@ -931,7 +998,7 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s
topicChild := child.GetChildByTag("body")
topicBytes, ok := topicChild.Content.([]byte)
if !ok {
- return nil, nil, fmt.Errorf("group change description has unexpected body: %s", &topicChild)
+ return nil, nil, nil, fmt.Errorf("group change description has unexpected body: %s", &topicChild)
}
topicStr = string(topicBytes)
}
@@ -973,12 +1040,12 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s
}
groupNode, ok := child.GetOptionalChildByTag("group")
if !ok {
- return nil, nil, &ElementMissingError{Tag: "group", In: "group link"}
+ return nil, nil, nil, &ElementMissingError{Tag: "group", In: "group link"}
}
var err error
evt.Link.Group, err = parseGroupLinkTargetNode(&groupNode)
if err != nil {
- return nil, nil, fmt.Errorf("failed to parse group link node in group change: %w", err)
+ return nil, nil, nil, fmt.Errorf("failed to parse group link node in group change: %w", err)
}
case "unlink":
evt.Unlink = &types.GroupLinkChange{
@@ -987,12 +1054,12 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s
}
groupNode, ok := child.GetOptionalChildByTag("group")
if !ok {
- return nil, nil, &ElementMissingError{Tag: "group", In: "group unlink"}
+ return nil, nil, nil, &ElementMissingError{Tag: "group", In: "group unlink"}
}
var err error
evt.Unlink.Group, err = parseGroupLinkTargetNode(&groupNode)
if err != nil {
- return nil, nil, fmt.Errorf("failed to parse group unlink node in group change: %w", err)
+ return nil, nil, nil, fmt.Errorf("failed to parse group unlink node in group change: %w", err)
}
case "membership_approval_mode":
evt.MembershipApprovalMode = &types.GroupMembershipApprovalMode{
@@ -1006,10 +1073,15 @@ func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []s
evt.UnknownChanges = append(evt.UnknownChanges, &child)
}
if !cag.OK() {
- return nil, nil, fmt.Errorf("group change %s element doesn't contain required attributes: %w", child.Tag, cag.Error())
+ return nil, nil, nil, fmt.Errorf("group change %s element doesn't contain required attributes: %w", child.Tag, cag.Error())
}
}
- return &evt, lidPairs, nil
+ return &evt, lidPairs, usernames, nil
+}
+
+func (cli *Client) parseGroupChange(node *waBinary.Node) (*events.GroupInfo, []store.LIDMapping, error) {
+ event, lidPairs, _, err := cli.parseGroupChangeWithUsernames(node)
+ return event, lidPairs, err
}
func (cli *Client) updateGroupParticipantCache(evt *events.GroupInfo) {
@@ -1043,20 +1115,29 @@ Outer:
}
}
-func (cli *Client) parseGroupNotification(node *waBinary.Node) (any, []store.LIDMapping, []store.RedactedPhoneEntry, error) {
+func (cli *Client) parseGroupNotificationWithUsernames(node *waBinary.Node) (any, []store.LIDMapping, []store.RedactedPhoneEntry, []store.ContactUsernameEntry, error) {
children := node.GetChildren()
if len(children) == 1 && children[0].Tag == "create" {
- return cli.parseGroupCreate(node, &children[0])
+ event, lidPairs, redactedPhones, err := cli.parseGroupCreate(node, &children[0])
+ if err != nil {
+ return nil, nil, nil, nil, err
+ }
+ return event, lidPairs, redactedPhones, groupContactUsernames(&event.GroupInfo), nil
} else {
- groupChange, lidPairs, err := cli.parseGroupChange(node)
+ groupChange, lidPairs, usernames, err := cli.parseGroupChangeWithUsernames(node)
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, nil, nil, err
}
cli.updateGroupParticipantCache(groupChange)
- return groupChange, lidPairs, nil, nil
+ return groupChange, lidPairs, nil, usernames, nil
}
}
+func (cli *Client) parseGroupNotification(node *waBinary.Node) (any, []store.LIDMapping, []store.RedactedPhoneEntry, error) {
+ event, lidPairs, redactedPhones, _, err := cli.parseGroupNotificationWithUsernames(node)
+ return event, lidPairs, redactedPhones, err
+}
+
// SetGroupJoinApprovalMode sets the group join approval mode to 'on' or 'off'.
func (cli *Client) SetGroupJoinApprovalMode(ctx context.Context, jid types.JID, mode bool) error {
modeStr := "off"
diff --git a/group_username_test.go b/group_username_test.go
new file mode 100644
index 000000000..d83be9b18
--- /dev/null
+++ b/group_username_test.go
@@ -0,0 +1,20 @@
+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/handshake.go b/handshake.go
index 8ce358231..67bc7608b 100644
--- a/handshake.go
+++ b/handshake.go
@@ -15,10 +15,10 @@ import (
"github.com/polymorfa/libsignal-protocol-go/ecc"
"google.golang.org/protobuf/proto"
- "go.mau.fi/whatsmeow/proto/waCert"
- "go.mau.fi/whatsmeow/proto/waWa6"
- "go.mau.fi/whatsmeow/socket"
- "go.mau.fi/whatsmeow/util/keys"
+ "github.com/polymorfa/hypermeow/proto/waCert"
+ "github.com/polymorfa/hypermeow/proto/waWa6"
+ "github.com/polymorfa/hypermeow/socket"
+ "github.com/polymorfa/hypermeow/util/keys"
)
const NoiseHandshakeResponseTimeout = 20 * time.Second
diff --git a/historysync_test.go b/historysync_test.go
new file mode 100644
index 000000000..33933f297
--- /dev/null
+++ b/historysync_test.go
@@ -0,0 +1,241 @@
+package whatsmeow
+
+import (
+ "bytes"
+ "compress/zlib"
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "google.golang.org/protobuf/proto"
+
+ waE2E "github.com/polymorfa/hypermeow/proto/waE2E"
+ waHistorySync "github.com/polymorfa/hypermeow/proto/waHistorySync"
+ "github.com/polymorfa/hypermeow/store"
+ waLog "github.com/polymorfa/hypermeow/util/log"
+)
+
+type historySyncDeviceContainer struct {
+ putContextErr chan error
+ putRelease chan struct{}
+}
+
+func (container *historySyncDeviceContainer) PutDevice(ctx context.Context, _ *store.Device) error {
+ container.putContextErr <- ctx.Err()
+ if container.putRelease != nil {
+ <-container.putRelease
+ }
+ return nil
+}
+
+func (*historySyncDeviceContainer) DeleteDevice(context.Context, *store.Device) error { return nil }
+
+type orderedHistorySyncDeviceContainer struct {
+ lock sync.Mutex
+ calls int
+ persisted string
+ firstEntered chan struct{}
+ secondEntered chan struct{}
+ secondCommitted chan struct{}
+ committed chan struct{}
+}
+
+func (container *orderedHistorySyncDeviceContainer) PutDevice(_ context.Context, device *store.Device) error {
+ container.lock.Lock()
+ container.calls++
+ call := container.calls
+ nonce := device.CompanionMetaNonce
+ container.lock.Unlock()
+
+ if call == 1 {
+ close(container.firstEntered)
+ select {
+ case <-container.secondEntered:
+ <-container.secondCommitted
+ case <-time.After(time.Second):
+ }
+ } else {
+ close(container.secondEntered)
+ }
+
+ container.lock.Lock()
+ container.persisted = nonce
+ container.lock.Unlock()
+ if call == 2 {
+ close(container.secondCommitted)
+ }
+ container.committed <- struct{}{}
+ return nil
+}
+
+func (*orderedHistorySyncDeviceContainer) DeleteDevice(context.Context, *store.Device) error {
+ return nil
+}
+
+func (container *orderedHistorySyncDeviceContainer) persistedNonce() string {
+ container.lock.Lock()
+ defer container.lock.Unlock()
+ return container.persisted
+}
+
+func historySyncNotificationWithNonce(t *testing.T, nonce string) *waE2E.HistorySyncNotification {
+ t.Helper()
+ syncType := waHistorySync.HistorySync_INITIAL_BOOTSTRAP
+ historyBytes, err := proto.Marshal(&waHistorySync.HistorySync{
+ SyncType: &syncType,
+ CompanionMetaNonce: proto.String(nonce),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var compressed bytes.Buffer
+ writer := zlib.NewWriter(&compressed)
+ if _, err = writer.Write(historyBytes); err != nil {
+ t.Fatal(err)
+ }
+ if err = writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return &waE2E.HistorySyncNotification{InitialHistBootstrapInlinePayload: compressed.Bytes()}
+}
+
+func TestHistorySyncReceiptPolicy(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ manual bool
+ disableManual bool
+ disableAll bool
+ want bool
+ }{
+ {name: "automatic", want: true},
+ {name: "manual default", manual: true, want: true},
+ {name: "manual disabled", manual: true, disableManual: true},
+ {name: "automatic disabled", disableAll: true},
+ {name: "manual globally disabled", manual: true, disableAll: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ client := &Client{
+ ManualHistorySyncDownload: test.manual,
+ DisableManualHistorySyncReceipt: test.disableManual,
+ DisableHistorySyncReceipt: test.disableAll,
+ }
+ if got := client.shouldSendHistorySyncReceipt(); got != test.want {
+ t.Fatalf("should send receipt = %t", got)
+ }
+ })
+ }
+}
+
+func TestHistorySyncSideEffectsCanBeDisabledIndependently(t *testing.T) {
+ client := &Client{DisableHistorySyncReceipt: true, DisableHistorySyncStorage: true, DisableHistorySyncMediaDelete: true}
+ if client.shouldSendHistorySyncReceipt() {
+ t.Fatal("receipt was enabled")
+ }
+ if client.shouldStoreHistorySync() || client.shouldDeleteHistorySyncMedia() {
+ t.Fatal("history side effect was enabled")
+ }
+}
+
+func TestHistorySyncDeletionKeepsCompanionNonce(t *testing.T) {
+ client := &Client{DisableHistorySyncStorage: true}
+ if !client.shouldStoreHistorySyncNonce() {
+ t.Fatal("media deletion did not retain its companion nonce")
+ }
+ client.DisableHistorySyncMediaDelete = true
+ if client.shouldStoreHistorySyncNonce() {
+ t.Fatal("nonce storage remained enabled without storage or deletion")
+ }
+}
+
+func TestAsyncHistorySyncNoncePersistenceIgnoresCallerCancellation(t *testing.T) {
+ container := &historySyncDeviceContainer{putContextErr: make(chan error, 1)}
+ client := &Client{
+ DisableHistorySyncStorage: true,
+ Log: waLog.Noop,
+ Store: &store.Device{Container: container},
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := client.DownloadHistorySync(ctx, historySyncNotificationWithNonce(t, "fresh"), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = <-container.putContextErr; err != nil {
+ t.Fatalf("nonce persistence inherited caller cancellation: %v", err)
+ }
+}
+
+func TestAsyncHistorySyncNoncePersistenceDoesNotBlockDownload(t *testing.T) {
+ container := &historySyncDeviceContainer{
+ putContextErr: make(chan error, 1),
+ putRelease: make(chan struct{}),
+ }
+ t.Cleanup(func() { close(container.putRelease) })
+ client := &Client{
+ DisableHistorySyncStorage: true,
+ Log: waLog.Noop,
+ Store: &store.Device{Container: container},
+ }
+ notification := historySyncNotificationWithNonce(t, "fresh")
+ done := make(chan error, 1)
+ go func() {
+ _, err := client.DownloadHistorySync(context.Background(), notification, false)
+ done <- err
+ }()
+
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("asynchronous nonce persistence blocked history sync download")
+ }
+ if nonce := client.currentCompanionMetaNonce(); nonce != "fresh" {
+ t.Fatalf("companion meta nonce = %q", nonce)
+ }
+ select {
+ case err := <-container.putContextErr:
+ if err != nil {
+ t.Fatalf("nonce persistence inherited caller cancellation: %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("asynchronous nonce persistence did not start")
+ }
+}
+
+func TestAsyncHistorySyncNoncePersistenceKeepsNewestNonce(t *testing.T) {
+ container := &orderedHistorySyncDeviceContainer{
+ firstEntered: make(chan struct{}),
+ secondEntered: make(chan struct{}),
+ secondCommitted: make(chan struct{}),
+ committed: make(chan struct{}, 2),
+ }
+ client := &Client{
+ DisableHistorySyncStorage: true,
+ Log: waLog.Noop,
+ Store: &store.Device{Container: container},
+ }
+ if _, err := client.DownloadHistorySync(context.Background(), historySyncNotificationWithNonce(t, "older"), false); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-container.firstEntered:
+ case <-time.After(time.Second):
+ t.Fatal("first nonce persistence did not start")
+ }
+ if _, err := client.DownloadHistorySync(context.Background(), historySyncNotificationWithNonce(t, "newest"), false); err != nil {
+ t.Fatal(err)
+ }
+ for range 2 {
+ select {
+ case <-container.committed:
+ case <-time.After(2 * time.Second):
+ t.Fatal("nonce persistence did not complete")
+ }
+ }
+ if nonce := container.persistedNonce(); nonce != "newest" {
+ t.Fatalf("persisted companion meta nonce = %q", nonce)
+ }
+}
diff --git a/internal/cmd/genmex/main.go b/internal/cmd/genmex/main.go
new file mode 100644
index 000000000..27f6ba238
--- /dev/null
+++ b/internal/cmd/genmex/main.go
@@ -0,0 +1,126 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "go/format"
+ "math/big"
+ "os"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+type operation struct {
+ DocumentID string `json:"documentId"`
+ Kind string `json:"kind"`
+ ResponseDiscriminator string `json:"responseDiscriminator"`
+}
+
+type spec struct {
+ SourceRevision string `json:"sourceRevision"`
+ WAVersion string `json:"waVersion"`
+ Operations map[string]operation `json:"operations"`
+}
+
+var (
+ hexRevision = regexp.MustCompile(`^[0-9a-f]{40}$`)
+ goName = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`)
+)
+
+func main() {
+ input := flag.String("input", "mex/spec.json", "input specification")
+ output := flag.String("output", "mex/bindings.go", "generated Go file")
+ check := flag.Bool("check", false, "fail when output differs")
+ flag.Parse()
+
+ data, err := os.ReadFile(*input)
+ checkErr(err)
+ var parsed spec
+ checkErr(json.Unmarshal(data, &parsed))
+ checkErr(validate(parsed))
+ generated, err := render(parsed)
+ checkErr(err)
+
+ if *check {
+ current, err := os.ReadFile(*output)
+ checkErr(err)
+ if !bytes.Equal(current, generated) {
+ checkErr(fmt.Errorf("%s is not generated from %s", *output, *input))
+ }
+ return
+ }
+ checkErr(os.WriteFile(*output, generated, 0o644))
+}
+
+func validate(parsed spec) error {
+ if !hexRevision.MatchString(parsed.SourceRevision) {
+ return fmt.Errorf("invalid source revision %q", parsed.SourceRevision)
+ }
+ if strings.TrimSpace(parsed.WAVersion) == "" {
+ return fmt.Errorf("missing WhatsApp Web version")
+ }
+ if len(parsed.Operations) == 0 {
+ return fmt.Errorf("no operations")
+ }
+ for name, op := range parsed.Operations {
+ if !goName.MatchString(name) {
+ return fmt.Errorf("invalid operation name %q", name)
+ }
+ id, ok := new(big.Int).SetString(op.DocumentID, 10)
+ if !ok || id.Sign() <= 0 {
+ return fmt.Errorf("invalid document ID for %s", name)
+ }
+ if op.Kind != "query" && op.Kind != "mutation" {
+ return fmt.Errorf("invalid operation kind %q for %s", op.Kind, name)
+ }
+ if strings.TrimSpace(op.ResponseDiscriminator) == "" {
+ return fmt.Errorf("missing response discriminator for %s", name)
+ }
+ }
+ return nil
+}
+
+func render(parsed spec) ([]byte, error) {
+ names := make([]string, 0, len(parsed.Operations))
+ for name := range parsed.Operations {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ var out strings.Builder
+ out.WriteString("// Code generated by internal/cmd/genmex; DO NOT EDIT.\n\n")
+ out.WriteString("package mex\n\n")
+ out.WriteString("type OperationName string\n\n")
+ out.WriteString("type OperationKind string\n\n")
+ out.WriteString("const (\n\tKindQuery OperationKind = \"query\"\n\tKindMutation OperationKind = \"mutation\"\n)\n\n")
+ fmt.Fprintf(&out, "const SourceRevision = %q\n\n", parsed.SourceRevision)
+ fmt.Fprintf(&out, "const WebVersion = %q\n\n", parsed.WAVersion)
+ out.WriteString("const (\n")
+ for _, name := range names {
+ fmt.Fprintf(&out, "\t%s OperationName = %q\n", name, name)
+ }
+ out.WriteString(")\n\n")
+ out.WriteString("type Operation struct {\n\tName OperationName\n\tDocumentID string\n\tKind OperationKind\n\tResponseDiscriminator string\n}\n\n")
+ out.WriteString("var operations = map[OperationName]Operation{\n")
+ for _, name := range names {
+ op := parsed.Operations[name]
+ kind := "KindQuery"
+ if op.Kind == "mutation" {
+ kind = "KindMutation"
+ }
+ fmt.Fprintf(&out, "\t%s: {Name: %s, DocumentID: %q, Kind: %s, ResponseDiscriminator: %q},\n", name, name, op.DocumentID, kind, op.ResponseDiscriminator)
+ }
+ out.WriteString("}\n\n")
+ out.WriteString("func Lookup(name OperationName) (Operation, bool) {\n\top, ok := operations[name]\n\treturn op, ok\n}\n")
+ return format.Source([]byte(out.String()))
+}
+
+func checkErr(err error) {
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/internals.go b/internals.go
index 9a1201c16..6abd42388 100644
--- a/internals.go
+++ b/internals.go
@@ -1,7 +1,7 @@
// GENERATED BY internals_generate.go; DO NOT EDIT
//go:generate go run internals_generate.go
-//go:generate goimports -local go.mau.fi/whatsmeow -w internals.go
+//go:generate goimports -local github.com/polymorfa/hypermeow -w internals.go
package whatsmeow
@@ -14,19 +14,19 @@ import (
"github.com/polymorfa/libsignal-protocol-go/keys/prekey"
- "go.mau.fi/whatsmeow/appstate"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/proto/waMsgApplication"
- "go.mau.fi/whatsmeow/proto/waMsgTransport"
- "go.mau.fi/whatsmeow/proto/waServerSync"
- "go.mau.fi/whatsmeow/socket"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "go.mau.fi/whatsmeow/util/keys"
+ "github.com/polymorfa/hypermeow/appstate"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/proto/waHistorySync"
+ "github.com/polymorfa/hypermeow/proto/waMsgApplication"
+ "github.com/polymorfa/hypermeow/proto/waMsgTransport"
+ "github.com/polymorfa/hypermeow/proto/waServerSync"
+ "github.com/polymorfa/hypermeow/socket"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
+ "github.com/polymorfa/hypermeow/util/keys"
)
type DangerousInternalClient struct {
@@ -311,6 +311,10 @@ func (int *DangerousInternalClient) HandleEncryptedMessage(ctx context.Context,
int.c.handleEncryptedMessage(ctx, node)
}
+func (int *DangerousInternalClient) SetSynchronousMessageNameUpdates(enabled bool) {
+ int.c.setSynchronousMessageNameUpdates(enabled)
+}
+
func (int *DangerousInternalClient) HandleUnencryptedMessage(ctx context.Context, node *waBinary.Node) {
int.c.handleUnencryptedMessage(ctx, node)
}
diff --git a/internals_generate.go b/internals_generate.go
index 9c4d6ee69..9d88b206c 100644
--- a/internals_generate.go
+++ b/internals_generate.go
@@ -22,7 +22,7 @@ import (
const header = `// GENERATED BY internals_generate.go; DO NOT EDIT
//go:generate go run internals_generate.go
-//go:generate goimports -local go.mau.fi/whatsmeow -w internals.go
+//go:generate goimports -local github.com/polymorfa/hypermeow -w internals.go
package whatsmeow
diff --git a/keepalive.go b/keepalive.go
index 8c50e7b36..b2a8e726c 100644
--- a/keepalive.go
+++ b/keepalive.go
@@ -11,8 +11,8 @@ import (
"math/rand/v2"
"time"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
var (
diff --git a/lid_resolution_test.go b/lid_resolution_test.go
new file mode 100644
index 000000000..59b7d2508
--- /dev/null
+++ b/lid_resolution_test.go
@@ -0,0 +1,47 @@
+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.go b/mediaconn.go
index 1451fbc44..4c9d04d43 100644
--- a/mediaconn.go
+++ b/mediaconn.go
@@ -11,8 +11,8 @@ import (
"fmt"
"time"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
)
//type MediaConnIP struct {
diff --git a/mediaretry.go b/mediaretry.go
index 2c6c28ec8..a0030965c 100644
--- a/mediaretry.go
+++ b/mediaretry.go
@@ -13,12 +13,12 @@ import (
"go.mau.fi/util/random"
"google.golang.org/protobuf/proto"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waMmsRetry"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "go.mau.fi/whatsmeow/util/gcmutil"
- "go.mau.fi/whatsmeow/util/hkdfutil"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waMmsRetry"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
+ "github.com/polymorfa/hypermeow/util/gcmutil"
+ "github.com/polymorfa/hypermeow/util/hkdfutil"
)
func getMediaRetryKey(mediaKey []byte) (cipherKey []byte) {
diff --git a/message.go b/message.go
index 328056d82..19e3ba5f5 100644
--- a/message.go
+++ b/message.go
@@ -27,15 +27,15 @@ import (
"go.mau.fi/util/random"
"google.golang.org/protobuf/proto"
- "go.mau.fi/whatsmeow/appstate"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/proto/waLidMigrationSyncPayload"
- "go.mau.fi/whatsmeow/proto/waWeb"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ "github.com/polymorfa/hypermeow/appstate"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/proto/waHistorySync"
+ "github.com/polymorfa/hypermeow/proto/waLidMigrationSyncPayload"
+ "github.com/polymorfa/hypermeow/proto/waWeb"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
var pbSerializer = store.SignalProtobufSerializer
@@ -52,12 +52,7 @@ func (cli *Client) handleEncryptedMessage(ctx context.Context, node *waBinary.No
} else if !info.RecipientAlt.IsEmpty() {
cli.StoreLIDPNMapping(ctx, info.RecipientAlt, info.Chat)
}
- if info.VerifiedName != nil && len(info.VerifiedName.Details.GetVerifiedName()) > 0 {
- go cli.updateBusinessName(ctx, info.Sender, info.SenderAlt, info, info.VerifiedName.Details.GetVerifiedName())
- }
- if len(info.PushName) > 0 && info.PushName != "-" && (cli.MessengerConfig == nil || info.PushName != "username") {
- go cli.updatePushName(ctx, info.Sender, info.SenderAlt, info, info.PushName)
- }
+ cli.updateMessageContactNames(ctx, info)
if info.Sender.Server == types.NewsletterServer {
var cancelled bool
defer cli.maybeDeferredAck(ctx, node)(&cancelled)
@@ -70,6 +65,32 @@ func (cli *Client) handleEncryptedMessage(ctx context.Context, node *waBinary.No
}
}
+func (cli *Client) setSynchronousMessageNameUpdates(enabled bool) {
+ cli.synchronousMessageNameUpdates.Store(enabled)
+}
+
+func (cli *Client) updateMessageContactNames(ctx context.Context, info *types.MessageInfo) {
+ synchronous := cli.synchronousMessageNameUpdates.Load()
+ var verifiedName string
+ if info.VerifiedName != nil {
+ verifiedName = info.VerifiedName.Details.GetVerifiedName()
+ }
+ if len(verifiedName) > 0 {
+ if synchronous {
+ cli.updateBusinessName(ctx, info.Sender, info.SenderAlt, info, verifiedName)
+ } else {
+ go cli.updateBusinessName(ctx, info.Sender, info.SenderAlt, info, verifiedName)
+ }
+ }
+ if len(info.PushName) > 0 && info.PushName != "-" && (cli.MessengerConfig == nil || info.PushName != "username") {
+ if synchronous {
+ cli.updatePushName(ctx, info.Sender, info.SenderAlt, info, info.PushName)
+ } else {
+ go cli.updatePushName(ctx, info.Sender, info.SenderAlt, info, info.PushName)
+ }
+ }
+}
+
func (cli *Client) handleUnencryptedMessage(ctx context.Context, node *waBinary.Node) {
info, err := cli.parseMessageInfo(node)
if err != nil {
@@ -343,14 +364,21 @@ func (cli *Client) decryptMessages(ctx context.Context, info *types.MessageInfo,
if info.SenderAlt.Server == types.HiddenUserServer {
senderEncryptionJID = info.SenderAlt
cli.migrateSessionStore(ctx, info.Sender, info.SenderAlt)
- } else if lid, err := cli.Store.LIDs.GetLIDForPN(ctx, info.Sender); err != nil {
- cli.Log.Errorf("Failed to get LID for %s: %v", info.Sender, err)
- } else if !lid.IsEmpty() {
+ } else if lid, err := cli.ResolveLID(ctx, info.Sender); err != nil {
+ cli.Log.Errorf("Failed to resolve LID for %s: %v", info.Sender, err)
+ if cli.SynchronousAck {
+ cli.sendRetryReceipt(ctx, node, info, false)
+ cli.sendAck(ctx, node, 0)
+ } else {
+ go cli.sendRetryReceipt(context.WithoutCancel(ctx), node, info, false)
+ go cli.sendAck(ctx, node, 0)
+ }
+ cli.dispatchEvent(&events.UndecryptableMessage{Info: *info})
+ return
+ } else {
cli.migrateSessionStore(ctx, info.Sender, lid)
senderEncryptionJID = lid
info.SenderAlt = lid
- } else {
- cli.Log.Warnf("No LID found for %s", info.Sender)
}
}
var recognizedStanza, protobufFailed bool
@@ -651,6 +679,11 @@ func (cli *Client) decryptGroupMsg(ctx context.Context, child *waBinary.Node, fr
const checkPadding = true
+type historySyncNotification struct {
+ messageID types.MessageID
+ notification *waE2E.HistorySyncNotification
+}
+
func isValidPadding(plaintext []byte) bool {
lastByte := plaintext[len(plaintext)-1]
expectedPadding := bytes.Repeat([]byte{lastByte}, int(lastByte))
@@ -713,15 +746,17 @@ func (cli *Client) handleHistorySyncNotificationLoop() {
ctx := cli.BackgroundEventCtx
for {
select {
- case notif := <-cli.historySyncNotifications:
- blob, err := cli.DownloadHistorySync(ctx, notif, false)
+ case queued := <-cli.historySyncNotifications:
+ blob, err := cli.DownloadHistorySync(ctx, queued.notification, false)
if err != nil {
cli.Log.Errorf("Failed to download history sync: %v", err)
} else {
- cli.dispatchEvent(&events.HistorySync{Data: blob, Notification: notif})
- err = cli.DeleteMedia(ctx, MediaHistory, notif.GetDirectPath(), notif.GetFileEncSHA256(), notif.GetEncHandle())
- if err != nil {
- cli.Log.Warnf("Failed to delete history sync media from server: %v", err)
+ cli.dispatchEvent(&events.HistorySync{Data: blob, Notification: queued.notification, MessageID: queued.messageID})
+ if cli.shouldDeleteHistorySyncMedia() {
+ err = cli.DeleteMedia(ctx, MediaHistory, queued.notification.GetDirectPath(), queued.notification.GetFileEncSHA256(), queued.notification.GetEncHandle())
+ if err != nil {
+ cli.Log.Warnf("Failed to delete history sync media from server: %v", err)
+ }
}
}
case <-time.After(1 * time.Minute):
@@ -783,13 +818,31 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History
return nil, fmt.Errorf("failed to unmarshal: %w", err)
}
cli.Log.Debugf("Received history sync (type %s, chunk %d, progress %d)", historySync.GetSyncType(), historySync.GetChunkOrder(), historySync.GetProgress())
+ storageCtx := ctx
+ if !synchronousStorage {
+ storageCtx = context.WithoutCancel(ctx)
+ }
+ nonceChanged := false
+ if historySync.CompanionMetaNonce != nil && cli.shouldStoreHistorySyncNonce() {
+ nonceChanged = cli.updateCompanionMetaNonce(historySync.GetCompanionMetaNonce())
+ }
+ storeHistorySync := cli.shouldStoreHistorySync()
doStorage := func(ctx context.Context) {
+ if nonceChanged {
+ cli.persistCompanionMetaNonce(ctx)
+ }
+ if !storeHistorySync {
+ return
+ }
if err := cli.storeNCTSalt(ctx, historySync.GetNctSalt()); err != nil {
cli.Log.Warnf("Failed to store NCT salt from history sync: %v", err)
}
if len(historySync.GetPhoneNumberToLidMappings()) > 0 {
cli.storeHistoricalPNLIDMappings(ctx, historySync.GetPhoneNumberToLidMappings())
}
+ if len(historySync.GetInlineContacts()) > 0 {
+ cli.storeHistoricalInlineContacts(ctx, historySync.GetInlineContacts())
+ }
if historySync.GetSyncType() == waHistorySync.HistorySync_PUSH_NAME {
cli.handleHistoricalPushNames(ctx, historySync.GetPushnames())
} else if len(historySync.GetConversations()) > 0 {
@@ -798,18 +851,61 @@ func (cli *Client) DownloadHistorySync(ctx context.Context, notif *waE2E.History
if historySync.GlobalSettings != nil {
cli.storeGlobalSettings(ctx, historySync.GlobalSettings)
}
- if historySync.CompanionMetaNonce != nil {
- cli.storeCompanionMetaNonce(ctx, historySync.GetCompanionMetaNonce())
- }
}
- if synchronousStorage {
- doStorage(ctx)
+ if !storeHistorySync && !nonceChanged {
+ return &historySync, nil
+ } else if synchronousStorage {
+ doStorage(storageCtx)
} else {
- go doStorage(context.WithoutCancel(ctx))
+ go doStorage(storageCtx)
}
return &historySync, nil
}
+func historicalInlineContactEntries(contacts []*waHistorySync.InlineContact) ([]store.ContactEntry, []store.LIDMapping) {
+ entries := make([]store.ContactEntry, 0, len(contacts))
+ mappings := make([]store.LIDMapping, 0, len(contacts))
+ for _, contact := range contacts {
+ if contact == nil {
+ continue
+ }
+ pn, _ := types.ParseJID(contact.GetPnJID())
+ lid, _ := types.ParseJID(contact.GetLidJID())
+ if pn.Server == types.DefaultUserServer && lid.Server == types.HiddenUserServer {
+ mappings = append(mappings, store.LIDMapping{PN: pn.ToNonAD(), LID: lid.ToNonAD()})
+ }
+ jid := lid.ToNonAD()
+ if jid.Server != types.HiddenUserServer {
+ jid = pn.ToNonAD()
+ }
+ if jid.Server != types.HiddenUserServer && jid.Server != types.DefaultUserServer {
+ continue
+ }
+ entries = append(entries, store.ContactEntry{
+ JID: jid,
+ FirstName: contact.GetFirstName(),
+ FullName: contact.GetFullName(),
+ Username: contact.GetUsername(),
+ UsernameSet: true,
+ })
+ }
+ return entries, mappings
+}
+
+func (cli *Client) storeHistoricalInlineContacts(ctx context.Context, contacts []*waHistorySync.InlineContact) {
+ entries, mappings := historicalInlineContactEntries(contacts)
+ if len(mappings) > 0 {
+ if err := cli.Store.LIDs.PutManyLIDMappings(ctx, mappings); err != nil {
+ cli.Log.Warnf("Failed to store LID mappings from inline contacts: %v", err)
+ }
+ }
+ if len(entries) > 0 && cli.Store.Contacts != nil {
+ if err := cli.Store.Contacts.PutAllContactNames(ctx, entries); err != nil {
+ cli.Log.Warnf("Failed to store inline contacts: %v", err)
+ }
+ }
+}
+
func (cli *Client) handleAppStateSyncKeyShare(ctx context.Context, keys *waE2E.AppStateSyncKeyShare) {
onlyResyncIfNotSynced := true
@@ -877,12 +973,12 @@ func (cli *Client) handleProtocolMessage(ctx context.Context, info *types.Messag
if protoMsg.GetHistorySyncNotification() != nil {
if !cli.ManualHistorySyncDownload {
- cli.historySyncNotifications <- protoMsg.HistorySyncNotification
+ cli.historySyncNotifications <- historySyncNotification{messageID: info.ID, notification: protoMsg.HistorySyncNotification}
if cli.historySyncHandlerStarted.CompareAndSwap(false, true) {
go cli.handleHistorySyncNotificationLoop()
}
}
- if !(cli.ManualHistorySyncDownload && cli.DisableManualHistorySyncReceipt) {
+ if cli.shouldSendHistorySyncReceipt() {
go func() {
err := cli.SendProtocolMessageReceipt(ctx, info.ID, types.ReceiptTypeHistorySync)
if err != nil {
@@ -921,6 +1017,22 @@ func (cli *Client) handleProtocolMessage(ctx context.Context, info *types.Messag
return
}
+func (cli *Client) shouldSendHistorySyncReceipt() bool {
+ return !cli.DisableHistorySyncReceipt && !(cli.ManualHistorySyncDownload && cli.DisableManualHistorySyncReceipt)
+}
+
+func (cli *Client) shouldStoreHistorySync() bool {
+ return !cli.DisableHistorySyncStorage
+}
+
+func (cli *Client) shouldDeleteHistorySyncMedia() bool {
+ return !cli.DisableHistorySyncMediaDelete
+}
+
+func (cli *Client) shouldStoreHistorySyncNonce() bool {
+ return cli.shouldStoreHistorySync() || cli.shouldDeleteHistorySyncMedia()
+}
+
func (cli *Client) processProtocolParts(ctx context.Context, info *types.MessageInfo, msg *waE2E.Message) (ok bool) {
ok = true
cli.storeMessageSecret(ctx, info, msg)
@@ -1089,18 +1201,36 @@ func (cli *Client) storeGlobalSettings(ctx context.Context, settings *waHistoryS
}
}
-func (cli *Client) storeCompanionMetaNonce(ctx context.Context, nonce string) {
- if nonce != "" && nonce != cli.Store.CompanionMetaNonce {
- cli.Store.CompanionMetaNonce = nonce
- err := cli.Store.Save(ctx)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).
- Msg("Failed to save companion meta nonce")
- } else {
- zerolog.Ctx(ctx).Debug().
- Msg("Saved companion meta nonce")
- }
+func (cli *Client) updateCompanionMetaNonce(nonce string) bool {
+ if nonce == "" || nonce == cli.currentCompanionMetaNonce() {
+ return false
+ }
+ cli.historySyncNonce.Store(&nonce)
+ return true
+}
+
+func (cli *Client) persistCompanionMetaNonce(ctx context.Context) {
+ cli.historySyncNonceSaveLock.Lock()
+ defer cli.historySyncNonceSaveLock.Unlock()
+ nonce := cli.currentCompanionMetaNonce()
+ if nonce == "" || nonce == cli.Store.CompanionMetaNonce {
+ return
+ }
+ cli.Store.CompanionMetaNonce = nonce
+ if err := cli.Store.Save(ctx); err != nil {
+ zerolog.Ctx(ctx).Err(err).
+ Msg("Failed to save companion meta nonce")
+ } else {
+ zerolog.Ctx(ctx).Debug().
+ Msg("Saved companion meta nonce")
+ }
+}
+
+func (cli *Client) currentCompanionMetaNonce() string {
+ if nonce := cli.historySyncNonce.Load(); nonce != nil {
+ return *nonce
}
+ return cli.Store.CompanionMetaNonce
}
func (cli *Client) storeHistoricalPNLIDMappings(ctx context.Context, mappings []*waHistorySync.PhoneNumberToLIDMapping) {
diff --git a/message_name_updates_test.go b/message_name_updates_test.go
new file mode 100644
index 000000000..bb66faf3e
--- /dev/null
+++ b/message_name_updates_test.go
@@ -0,0 +1,66 @@
+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/mex/bindings.go b/mex/bindings.go
new file mode 100644
index 000000000..a89bdef0e
--- /dev/null
+++ b/mex/bindings.go
@@ -0,0 +1,50 @@
+// Code generated by internal/cmd/genmex; DO NOT EDIT.
+
+package mex
+
+type OperationName string
+
+type OperationKind string
+
+const (
+ KindQuery OperationKind = "query"
+ KindMutation OperationKind = "mutation"
+)
+
+const SourceRevision = "74509efe262b37b26bed05486c9f4160db5e841b"
+
+const WebVersion = "2.3000.1044776264"
+
+const (
+ BizCreateOrder OperationName = "BizCreateOrder"
+ BizQueryOrder OperationName = "BizQueryOrder"
+ DeleteNewsletter OperationName = "DeleteNewsletter"
+ QueryCatalog OperationName = "QueryCatalog"
+ QueryCatalogProduct OperationName = "QueryCatalogProduct"
+ QueryProductCollections OperationName = "QueryProductCollections"
+ QueryProductListCatalog OperationName = "QueryProductListCatalog"
+ QueryProductSingleCollection OperationName = "QueryProductSingleCollection"
+)
+
+type Operation struct {
+ Name OperationName
+ DocumentID string
+ Kind OperationKind
+ ResponseDiscriminator string
+}
+
+var operations = map[OperationName]Operation{
+ BizCreateOrder: {Name: BizCreateOrder, DocumentID: "26486627094287046", Kind: KindMutation, ResponseDiscriminator: "xwa_checkout_place_order"},
+ BizQueryOrder: {Name: BizQueryOrder, DocumentID: "26593811266898374", Kind: KindQuery, ResponseDiscriminator: "xwa_checkout_get_order_info"},
+ DeleteNewsletter: {Name: DeleteNewsletter, DocumentID: "30062808666639665", Kind: KindMutation, ResponseDiscriminator: "xwa2_newsletter_delete_v2"},
+ QueryCatalog: {Name: QueryCatalog, DocumentID: "30445081048424116", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_product_catalog"},
+ QueryCatalogProduct: {Name: QueryCatalogProduct, DocumentID: "9660926520672123", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_product"},
+ QueryProductCollections: {Name: QueryProductCollections, DocumentID: "9430970660362540", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_collections"},
+ QueryProductListCatalog: {Name: QueryProductListCatalog, DocumentID: "30125049463760630", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_product_list"},
+ QueryProductSingleCollection: {Name: QueryProductSingleCollection, DocumentID: "9546992575408789", Kind: KindQuery, ResponseDiscriminator: "xwa_product_catalog_get_single_collection"},
+}
+
+func Lookup(name OperationName) (Operation, bool) {
+ op, ok := operations[name]
+ return op, ok
+}
diff --git a/mex/bindings_test.go b/mex/bindings_test.go
new file mode 100644
index 000000000..04ef94f48
--- /dev/null
+++ b/mex/bindings_test.go
@@ -0,0 +1,31 @@
+package mex
+
+import "testing"
+
+func TestCatalogBindingsMatchPinnedSpec(t *testing.T) {
+ if SourceRevision != "74509efe262b37b26bed05486c9f4160db5e841b" {
+ t.Fatalf("source revision = %q", SourceRevision)
+ }
+ tests := map[OperationName]string{
+ DeleteNewsletter: "30062808666639665",
+ QueryCatalog: "30445081048424116",
+ QueryCatalogProduct: "9660926520672123",
+ QueryProductCollections: "9430970660362540",
+ QueryProductListCatalog: "30125049463760630",
+ QueryProductSingleCollection: "9546992575408789",
+ BizCreateOrder: "26486627094287046",
+ BizQueryOrder: "26593811266898374",
+ }
+ for name, wantID := range tests {
+ binding, ok := Lookup(name)
+ if !ok || binding.DocumentID != wantID {
+ t.Errorf("%s = %#v, %t; want document ID %s", name, binding, ok, wantID)
+ }
+ }
+}
+
+func TestLookupRejectsUnknownOperation(t *testing.T) {
+ if _, ok := Lookup(OperationName("unknown")); ok {
+ t.Fatal("unknown operation unexpectedly resolved")
+ }
+}
diff --git a/mex/generate.go b/mex/generate.go
new file mode 100644
index 000000000..6d4f332d4
--- /dev/null
+++ b/mex/generate.go
@@ -0,0 +1,3 @@
+package mex
+
+//go:generate go run ../internal/cmd/genmex -input spec.json -output bindings.go
diff --git a/mex/spec.json b/mex/spec.json
new file mode 100644
index 000000000..014f7d617
--- /dev/null
+++ b/mex/spec.json
@@ -0,0 +1,46 @@
+{
+ "sourceRevision": "74509efe262b37b26bed05486c9f4160db5e841b",
+ "waVersion": "2.3000.1044776264",
+ "operations": {
+ "DeleteNewsletter": {
+ "documentId": "30062808666639665",
+ "kind": "mutation",
+ "responseDiscriminator": "xwa2_newsletter_delete_v2"
+ },
+ "BizCreateOrder": {
+ "documentId": "26486627094287046",
+ "kind": "mutation",
+ "responseDiscriminator": "xwa_checkout_place_order"
+ },
+ "BizQueryOrder": {
+ "documentId": "26593811266898374",
+ "kind": "query",
+ "responseDiscriminator": "xwa_checkout_get_order_info"
+ },
+ "QueryCatalog": {
+ "documentId": "30445081048424116",
+ "kind": "query",
+ "responseDiscriminator": "xwa_product_catalog_get_product_catalog"
+ },
+ "QueryCatalogProduct": {
+ "documentId": "9660926520672123",
+ "kind": "query",
+ "responseDiscriminator": "xwa_product_catalog_get_product"
+ },
+ "QueryProductCollections": {
+ "documentId": "9430970660362540",
+ "kind": "query",
+ "responseDiscriminator": "xwa_product_catalog_get_collections"
+ },
+ "QueryProductListCatalog": {
+ "documentId": "30125049463760630",
+ "kind": "query",
+ "responseDiscriminator": "xwa_product_catalog_get_product_list"
+ },
+ "QueryProductSingleCollection": {
+ "documentId": "9546992575408789",
+ "kind": "query",
+ "responseDiscriminator": "xwa_product_catalog_get_single_collection"
+ }
+ }
+}
diff --git a/msgsecret.go b/msgsecret.go
index 394e8387c..16700eece 100644
--- a/msgsecret.go
+++ b/msgsecret.go
@@ -17,12 +17,12 @@ import (
"go.mau.fi/util/random"
"google.golang.org/protobuf/proto"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "go.mau.fi/whatsmeow/util/gcmutil"
- "go.mau.fi/whatsmeow/util/hkdfutil"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
+ "github.com/polymorfa/hypermeow/util/gcmutil"
+ "github.com/polymorfa/hypermeow/util/hkdfutil"
)
type MsgSecretType string
diff --git a/newsletter.go b/newsletter.go
index f9022bf52..af29ac0b3 100644
--- a/newsletter.go
+++ b/newsletter.go
@@ -12,10 +12,10 @@ import (
"fmt"
"time"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waWa6"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waWa6"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
)
// NewsletterSubscribeLiveUpdates subscribes to receive live updates from a WhatsApp channel temporarily (for the duration returned).
@@ -118,6 +118,7 @@ const (
mutationCreateNewsletter = "6234210096708695"
mutationUnfollowNewsletter = "6392786840836363"
mutationFollowNewsletter = "9926858900719341"
+ mutationDeleteNewsletter = "30062808666639665"
// desktop & mobile
queryFetchNewsletterDesktop = "9779843322044422"
@@ -155,6 +156,8 @@ func convertQueryID(cli *Client, queryID string) string {
return mutationUnfollowNewsletterDesktop
case mutationFollowNewsletter:
return mutationFollowNewsletterDesktop
+ case mutationDeleteNewsletter:
+ return ""
default:
return queryID
}
@@ -168,6 +171,9 @@ func (cli *Client) sendMexIQ(ctx context.Context, queryID string, variables any)
return nil, fmt.Errorf("argo decoding is currently broken")
}
queryID = convertQueryID(cli, queryID)
+ if queryID == "" {
+ return nil, fmt.Errorf("MEX query is unsupported for this client platform")
+ }
payload, err := json.Marshal(map[string]any{
"variables": variables,
})
diff --git a/newsletter_delete.go b/newsletter_delete.go
new file mode 100644
index 000000000..bfcca4342
--- /dev/null
+++ b/newsletter_delete.go
@@ -0,0 +1,76 @@
+package whatsmeow
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/polymorfa/hypermeow/mex"
+ "github.com/polymorfa/hypermeow/types"
+)
+
+type deleteNewsletterVariables struct {
+ NewsletterID string `json:"newsletter_id"`
+}
+
+func buildDeleteNewsletterVariables(jid types.JID) (deleteNewsletterVariables, error) {
+ if jid.IsEmpty() || jid.User == "" || jid.Server != types.NewsletterServer {
+ return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID must use the newsletter server")
+ }
+ if len(jid.User) > 256 {
+ return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID user exceeds 256 bytes")
+ }
+ for _, character := range jid.User {
+ if character < '0' || character > '9' {
+ return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID user must be numeric")
+ }
+ }
+ if jid.RawAgent != 0 || jid.Device != 0 || jid.Integrator != 0 {
+ return deleteNewsletterVariables{}, fmt.Errorf("newsletter JID must not identify a device")
+ }
+ return deleteNewsletterVariables{NewsletterID: jid.String()}, nil
+}
+
+func decodeDeleteNewsletterResponse(data json.RawMessage, requested types.JID) error {
+ var response struct {
+ Deleted *struct {
+ ID string `json:"id"`
+ State *struct {
+ Type string `json:"type"`
+ } `json:"state"`
+ } `json:"xwa2_newsletter_delete_v2"`
+ }
+ if err := json.Unmarshal(data, &response); err != nil {
+ return fmt.Errorf("decode newsletter deletion response: %w", err)
+ }
+ if response.Deleted == nil {
+ return fmt.Errorf("newsletter deletion response is missing xwa2_newsletter_delete_v2")
+ }
+ if response.Deleted.ID != requested.String() {
+ return fmt.Errorf("newsletter deletion response ID %q does not match %q", response.Deleted.ID, requested)
+ }
+ if response.Deleted.State == nil || response.Deleted.State.Type != "DELETED" {
+ return fmt.Errorf("newsletter deletion response did not confirm DELETED state")
+ }
+ return nil
+}
+
+// DeleteNewsletter permanently deletes a WhatsApp channel owned by the client.
+func (cli *Client) DeleteNewsletter(ctx context.Context, jid types.JID) error {
+ if cli == nil {
+ return ErrClientIsNil
+ }
+ variables, err := buildDeleteNewsletterVariables(jid)
+ if err != nil {
+ return err
+ }
+ binding, ok := mex.Lookup(mex.DeleteNewsletter)
+ if !ok {
+ return fmt.Errorf("missing DeleteNewsletter MEX binding")
+ }
+ data, err := cli.sendMexIQ(ctx, binding.DocumentID, variables)
+ if err != nil {
+ return fmt.Errorf("delete newsletter: %w", err)
+ }
+ return decodeDeleteNewsletterResponse(data, jid)
+}
diff --git a/newsletter_delete_test.go b/newsletter_delete_test.go
new file mode 100644
index 000000000..fa78af925
--- /dev/null
+++ b/newsletter_delete_test.go
@@ -0,0 +1,82 @@
+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.go b/notification.go
index bfbefefc5..7bfe51954 100644
--- a/notification.go
+++ b/notification.go
@@ -15,12 +15,12 @@ import (
"google.golang.org/protobuf/proto"
- "go.mau.fi/whatsmeow/appstate"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ "github.com/polymorfa/hypermeow/appstate"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
func (cli *Client) handleEncryptNotification(ctx context.Context, node *waBinary.Node) {
@@ -39,14 +39,7 @@ func (cli *Client) handleEncryptNotification(ctx context.Context, node *waBinary
}
} else if _, ok := node.GetOptionalChildByTag("identity"); ok {
cli.Log.Debugf("Got identity change for %s: %s, deleting all identities/sessions for that number", from, node)
- err := cli.Store.Identities.DeleteAllIdentities(ctx, from.User)
- if err != nil {
- cli.Log.Warnf("Failed to delete all identities of %s from store after identity change: %v", from, err)
- }
- err = cli.Store.Sessions.DeleteAllSessions(ctx, from.User)
- if err != nil {
- cli.Log.Warnf("Failed to delete all sessions of %s from store after identity change: %v", from, err)
- }
+ cli.deleteIdentityChangeState(ctx, from)
ts := node.AttrGetter().UnixTime("t")
storageLID := cli.resolveTCTokenStorageLID(ctx, from)
pt, err := cli.Store.PrivacyTokens.GetPrivacyToken(ctx, storageLID)
@@ -70,6 +63,45 @@ func (cli *Client) handleEncryptNotification(ctx context.Context, node *waBinary
}
}
+func (cli *Client) deleteIdentityChangeState(ctx context.Context, from types.JID) {
+ users := identityChangeSignalUsers(from)
+ if cli.Store.LIDs != nil {
+ var alternate types.JID
+ var err error
+ switch from.Server {
+ case types.DefaultUserServer, types.HostedServer:
+ alternate, err = cli.Store.LIDs.GetLIDForPN(ctx, types.NewJID(from.User, types.DefaultUserServer))
+ case types.HiddenUserServer, types.HostedLIDServer:
+ alternate, err = cli.Store.LIDs.GetPNForLID(ctx, types.NewJID(from.User, types.HiddenUserServer))
+ }
+ if err != nil {
+ cli.Log.Warnf("Failed to resolve alternate JID for identity change from %s: %v", from, err)
+ } else if !alternate.IsEmpty() {
+ users = append(users, identityChangeSignalUsers(alternate)...)
+ }
+ }
+ users = slices.Compact(users)
+ for _, user := range users {
+ if err := cli.Store.Identities.DeleteAllIdentities(ctx, user); err != nil {
+ cli.Log.Warnf("Failed to delete identities for %s after identity change from %s: %v", user, from, err)
+ }
+ if err := cli.Store.Sessions.DeleteAllSessions(ctx, user); err != nil {
+ cli.Log.Warnf("Failed to delete sessions for %s after identity change from %s: %v", user, from, err)
+ }
+ }
+}
+
+func identityChangeSignalUsers(jid types.JID) []string {
+ switch jid.Server {
+ case types.DefaultUserServer, types.HostedServer:
+ return []string{jid.User, jid.User + "_128"}
+ case types.HiddenUserServer, types.HostedLIDServer:
+ return []string{jid.User + "_1", jid.User + "_129"}
+ default:
+ return []string{jid.SignalAddressUser()}
+ }
+}
+
func (cli *Client) handleAppStateNotification(ctx context.Context, node *waBinary.Node) {
for _, collection := range node.GetChildrenByTag("collection") {
ag := collection.AttrGetter()
@@ -122,17 +154,18 @@ func (cli *Client) handleDeviceNotification(ctx context.Context, node *waBinary.
if fromLID != nil {
cli.StoreLIDPNMapping(ctx, *fromLID, from)
}
- cached, ok := cli.userDevicesCache[from]
- if !ok {
- cli.Log.Debugf("No device list cached for %s, ignoring device list notification", from)
- return
- }
+ cached, hasCachedPN := cli.userDevicesCache[from]
var cachedLID deviceCache
var cachedLIDHash string
+ var hasCachedLID bool
if fromLID != nil {
- cachedLID = cli.userDevicesCache[*fromLID]
+ cachedLID, hasCachedLID = cli.userDevicesCache[*fromLID]
cachedLIDHash = participantListHashV2(cachedLID.devices)
}
+ if !hasCachedPN && !hasCachedLID {
+ cli.Log.Debugf("No device list cached for %s, ignoring device list notification", from)
+ return
+ }
cachedParticipantHash := participantListHashV2(cached.devices)
for _, child := range node.GetChildren() {
cag := child.AttrGetter()
@@ -143,15 +176,27 @@ func (cli *Client) handleDeviceNotification(ctx context.Context, node *waBinary.
changedDeviceLID := deviceChild.AttrGetter().OptionalJID("lid")
switch child.Tag {
case "add":
- cached.devices = append(cached.devices, changedDeviceJID)
- if changedDeviceLID != nil {
+ if hasCachedLID && (changedDeviceLID == nil || deviceLIDHash == "") {
+ delete(cli.userDevicesCache, *fromLID)
+ hasCachedLID = false
+ }
+ if hasCachedPN {
+ cached.devices = append(cached.devices, changedDeviceJID)
+ }
+ if hasCachedLID && changedDeviceLID != nil {
cachedLID.devices = append(cachedLID.devices, *changedDeviceLID)
}
case "remove":
- cached.devices = slices.DeleteFunc(cached.devices, func(existing types.JID) bool {
- return existing == changedDeviceJID
- })
- if changedDeviceLID != nil {
+ if hasCachedLID && (changedDeviceLID == nil || deviceLIDHash == "") {
+ delete(cli.userDevicesCache, *fromLID)
+ hasCachedLID = false
+ }
+ if hasCachedPN {
+ cached.devices = slices.DeleteFunc(cached.devices, func(existing types.JID) bool {
+ return existing == changedDeviceJID
+ })
+ }
+ if hasCachedLID && changedDeviceLID != nil {
cachedLID.devices = slices.DeleteFunc(cachedLID.devices, func(existing types.JID) bool {
return existing == *changedDeviceLID
})
@@ -160,20 +205,25 @@ func (cli *Client) handleDeviceNotification(ctx context.Context, node *waBinary.
// Exact meaning of "update" is unknown, clear device list cache to be safe
cli.Log.Debugf("%s's device list updated, dropping cached devices", from)
delete(cli.userDevicesCache, from)
+ if fromLID != nil {
+ delete(cli.userDevicesCache, *fromLID)
+ }
continue
default:
cli.Log.Debugf("Unknown device list change tag %s", child.Tag)
continue
}
- newParticipantHash := participantListHashV2(cached.devices)
- if newParticipantHash == deviceHash {
- cli.Log.Debugf("%s's device list hash changed from %s to %s (%s). New hash matches", from, cachedParticipantHash, deviceHash, child.Tag)
- putBoundedCache(ensureMap(&cli.userDevicesCache), from, cached, maxUserDeviceCacheEntries)
- } else {
- cli.Log.Warnf("%s's device list hash changed from %s to %s (%s). New hash doesn't match (%s)", from, cachedParticipantHash, deviceHash, child.Tag, newParticipantHash)
- delete(cli.userDevicesCache, from)
+ if hasCachedPN {
+ newParticipantHash := participantListHashV2(cached.devices)
+ if newParticipantHash == deviceHash {
+ cli.Log.Debugf("%s's device list hash changed from %s to %s (%s). New hash matches", from, cachedParticipantHash, deviceHash, child.Tag)
+ putBoundedCache(ensureMap(&cli.userDevicesCache), from, cached, maxUserDeviceCacheEntries)
+ } else {
+ cli.Log.Warnf("%s's device list hash changed from %s to %s (%s). New hash doesn't match (%s)", from, cachedParticipantHash, deviceHash, child.Tag, newParticipantHash)
+ delete(cli.userDevicesCache, from)
+ }
}
- if fromLID != nil && changedDeviceLID != nil && deviceLIDHash != "" {
+ if fromLID != nil && hasCachedLID && changedDeviceLID != nil && deviceLIDHash != "" {
newLIDParticipantHash := participantListHashV2(cachedLID.devices)
if newLIDParticipantHash == deviceLIDHash {
cli.Log.Debugf("%s's device list hash changed from %s to %s (%s). New hash matches", fromLID, cachedLIDHash, deviceLIDHash, child.Tag)
@@ -476,7 +526,7 @@ func (cli *Client) handleNotification(ctx context.Context, node *waBinary.Node)
case "fbid:devices":
cli.handleFBDeviceNotification(ctx, node)
case "w:gp2":
- evt, lidPairs, redactedPhones, err := cli.parseGroupNotification(node)
+ evt, lidPairs, redactedPhones, usernames, err := cli.parseGroupNotificationWithUsernames(node)
if err != nil {
cli.Log.Errorf("Failed to parse group notification: %v", err)
} else {
@@ -488,6 +538,7 @@ func (cli *Client) handleNotification(ctx context.Context, node *waBinary.Node)
if err != nil {
cli.Log.Warnf("Failed to store redacted phones from group notification: %v", err)
}
+ cli.storeContactUsernamesBestEffort(ctx, usernames)
cancelled = cli.dispatchEvent(evt)
}
case "picture":
@@ -506,9 +557,11 @@ func (cli *Client) handleNotification(ctx context.Context, node *waBinary.Node)
cli.handleStatusNotification(ctx, node)
case "passkey_prologue_request":
cli.handlePasskeyNotification(ctx, node)
+ case "business":
+ cli.handleQueuedBusinessCatalogNotification(node)
case "crsc_continuation":
go cli.tryHandlePasskeyContinuationNotification(ctx, node)
- // Other types: business, disappearing_mode, server, status, pay, psa
+ // Other types: disappearing_mode, server, status, pay, psa
default:
cli.Log.Debugf("Unhandled notification with type %s", notifType)
}
diff --git a/notification_device_test.go b/notification_device_test.go
new file mode 100644
index 000000000..d8bf1ac00
--- /dev/null
+++ b/notification_device_test.go
@@ -0,0 +1,94 @@
+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
new file mode 100644
index 000000000..8046f66da
--- /dev/null
+++ b/notification_identity_test.go
@@ -0,0 +1,96 @@
+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/pair-code.go b/pair-code.go
index 6cda22e9b..b7f2c0db8 100644
--- a/pair-code.go
+++ b/pair-code.go
@@ -21,10 +21,10 @@ import (
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/pbkdf2"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/util/hkdfutil"
- "go.mau.fi/whatsmeow/util/keys"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/util/hkdfutil"
+ "github.com/polymorfa/hypermeow/util/keys"
)
// PairClientType is the type of client to use with PairCode.
diff --git a/pair-passkey.go b/pair-passkey.go
index 4ec037185..c9e25f76a 100644
--- a/pair-passkey.go
+++ b/pair-passkey.go
@@ -18,14 +18,14 @@ import (
"golang.org/x/crypto/curve25519"
"google.golang.org/protobuf/proto"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waCompanionReg"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "go.mau.fi/whatsmeow/util/gcmutil"
- "go.mau.fi/whatsmeow/util/hkdfutil"
- "go.mau.fi/whatsmeow/util/keys"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waCompanionReg"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
+ "github.com/polymorfa/hypermeow/util/gcmutil"
+ "github.com/polymorfa/hypermeow/util/hkdfutil"
+ "github.com/polymorfa/hypermeow/util/keys"
)
type passkeyLinkingCache struct {
diff --git a/pair.go b/pair.go
index 6b9c8dc57..0968ea599 100644
--- a/pair.go
+++ b/pair.go
@@ -17,14 +17,14 @@ import (
"github.com/polymorfa/libsignal-protocol-go/ecc"
"google.golang.org/protobuf/proto"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waAdv"
- "go.mau.fi/whatsmeow/proto/waCompanionReg"
- "go.mau.fi/whatsmeow/proto/waWa6"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "go.mau.fi/whatsmeow/util/keys"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waAdv"
+ "github.com/polymorfa/hypermeow/proto/waCompanionReg"
+ "github.com/polymorfa/hypermeow/proto/waWa6"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
+ "github.com/polymorfa/hypermeow/util/keys"
)
var (
diff --git a/phone_number_message.go b/phone_number_message.go
new file mode 100644
index 000000000..1db9add66
--- /dev/null
+++ b/phone_number_message.go
@@ -0,0 +1,18 @@
+package whatsmeow
+
+import "github.com/polymorfa/hypermeow/proto/waE2E"
+
+func BuildRequestPhoneNumberMessage(contextInfo *waE2E.ContextInfo) *waE2E.Message {
+ return &waE2E.Message{
+ RequestPhoneNumberMessage: &waE2E.RequestPhoneNumberMessage{
+ ContextInfo: contextInfo,
+ },
+ }
+}
+
+func BuildSharePhoneNumberMessage() *waE2E.Message {
+ messageType := waE2E.ProtocolMessage_SHARE_PHONE_NUMBER
+ return &waE2E.Message{
+ ProtocolMessage: &waE2E.ProtocolMessage{Type: &messageType},
+ }
+}
diff --git a/phone_number_message_test.go b/phone_number_message_test.go
new file mode 100644
index 000000000..bf75ce73f
--- /dev/null
+++ b/phone_number_message_test.go
@@ -0,0 +1,35 @@
+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/prekeys.go b/prekeys.go
index 563a82250..cc2dadd57 100644
--- a/prekeys.go
+++ b/prekeys.go
@@ -17,9 +17,9 @@ import (
"github.com/polymorfa/libsignal-protocol-go/keys/prekey"
"github.com/polymorfa/libsignal-protocol-go/util/optional"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/util/keys"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/util/keys"
)
const (
diff --git a/presence.go b/presence.go
index e84f51a50..eb378620a 100644
--- a/presence.go
+++ b/presence.go
@@ -10,9 +10,9 @@ import (
"context"
"fmt"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
func (cli *Client) handleChatState(ctx context.Context, node *waBinary.Node) {
diff --git a/privacysettings.go b/privacysettings.go
index bfe3de802..db4b226e7 100644
--- a/privacysettings.go
+++ b/privacysettings.go
@@ -11,9 +11,9 @@ import (
"strconv"
"time"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
// TryFetchPrivacySettings will fetch the user's privacy settings, either from the in-memory cache or from the server.
diff --git a/privacysettings_test.go b/privacysettings_test.go
index ac0398a22..61fe68961 100644
--- a/privacysettings_test.go
+++ b/privacysettings_test.go
@@ -3,7 +3,7 @@ package whatsmeow
import (
"testing"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/types"
)
func TestApplyPrivacySettingUpdatesEveryCategory(t *testing.T) {
diff --git a/proto/armadilloutil/decode.go b/proto/armadilloutil/decode.go
index 31389d33c..0562b1c90 100644
--- a/proto/armadilloutil/decode.go
+++ b/proto/armadilloutil/decode.go
@@ -6,7 +6,7 @@ import (
"google.golang.org/protobuf/proto"
- "go.mau.fi/whatsmeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
)
var ErrUnsupportedVersion = errors.New("unsupported subprotocol version")
diff --git a/proto/extra.go b/proto/extra.go
index 81c706f9e..ebd98b376 100644
--- a/proto/extra.go
+++ b/proto/extra.go
@@ -3,13 +3,13 @@ package armadillo
import (
"google.golang.org/protobuf/proto"
- "go.mau.fi/whatsmeow/proto/instamadilloAddMessage"
- "go.mau.fi/whatsmeow/proto/instamadilloDeleteMessage"
- "go.mau.fi/whatsmeow/proto/instamadilloSupplementMessage"
- "go.mau.fi/whatsmeow/proto/waArmadilloApplication"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waConsumerApplication"
- "go.mau.fi/whatsmeow/proto/waMultiDevice"
+ "github.com/polymorfa/hypermeow/proto/instamadilloAddMessage"
+ "github.com/polymorfa/hypermeow/proto/instamadilloDeleteMessage"
+ "github.com/polymorfa/hypermeow/proto/instamadilloSupplementMessage"
+ "github.com/polymorfa/hypermeow/proto/waArmadilloApplication"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waConsumerApplication"
+ "github.com/polymorfa/hypermeow/proto/waMultiDevice"
)
type MessageApplicationSub interface {
diff --git a/proto/instamadilloAddMessage/InstamadilloAddMessage.pb.go b/proto/instamadilloAddMessage/InstamadilloAddMessage.pb.go
index 43c403624..a89bed661 100644
--- a/proto/instamadilloAddMessage/InstamadilloAddMessage.pb.go
+++ b/proto/instamadilloAddMessage/InstamadilloAddMessage.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloAddMessage/InstamadilloAddMessage.proto
package instamadilloAddMessage
@@ -14,13 +14,13 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- instamadilloCoreTypeActionLog "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeActionLog"
- instamadilloCoreTypeAdminMessage "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeAdminMessage"
- instamadilloCoreTypeCollection "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeCollection"
- instamadilloCoreTypeLink "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeLink"
- instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia"
- instamadilloCoreTypeText "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeText"
- instamadilloXmaContentRef "go.mau.fi/whatsmeow/proto/instamadilloXmaContentRef"
+ instamadilloCoreTypeActionLog "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeActionLog"
+ instamadilloCoreTypeAdminMessage "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeAdminMessage"
+ instamadilloCoreTypeCollection "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeCollection"
+ instamadilloCoreTypeLink "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeLink"
+ instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia"
+ instamadilloCoreTypeText "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeText"
+ instamadilloXmaContentRef "github.com/polymorfa/hypermeow/proto/instamadilloXmaContentRef"
)
const (
@@ -882,7 +882,7 @@ const file_instamadilloAddMessage_InstamadilloAddMessage_proto_rawDesc = "" +
"#PLACEHOLDER_TYPE_DECRYPTION_FAILURE\x10\x01\x12.\n" +
"*PLACEHOLDER_TYPE_NOT_SUPPORTED_NEED_UPDATE\x10\x02\x12'\n" +
"#PLACEHOLDER_TYPE_DEVICE_UNAVAILABLE\x10\x03\x122\n" +
- ".PLACEHOLDER_TYPE_NOT_SUPPORTED_NOT_RECOVERABLE\x10\x04B2Z0go.mau.fi/whatsmeow/proto/instamadilloAddMessage"
+ ".PLACEHOLDER_TYPE_NOT_SUPPORTED_NOT_RECOVERABLE\x10\x04B=Z;github.com/polymorfa/hypermeow/proto/instamadilloAddMessage"
var (
file_instamadilloAddMessage_InstamadilloAddMessage_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloAddMessage/InstamadilloAddMessage.proto b/proto/instamadilloAddMessage/InstamadilloAddMessage.proto
index 399256797..570d6ef6a 100644
--- a/proto/instamadilloAddMessage/InstamadilloAddMessage.proto
+++ b/proto/instamadilloAddMessage/InstamadilloAddMessage.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloAddMessage;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloAddMessage";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloAddMessage";
import "instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto";
import "instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto";
diff --git a/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.pb.go b/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.pb.go
index a20af395c..51c1614fb 100644
--- a/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.pb.go
+++ b/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto
package instamadilloCoreTypeActionLog
@@ -141,7 +141,7 @@ const file_instamadilloCoreTypeActionLog_InstamadilloCoreTypeActionLog_proto_raw
"\x11actionLogReaction\x18\x01 \x01(\v20.InstamadilloCoreTypeActionLog.ActionLogReactionH\x00R\x11actionLogReactionB\x12\n" +
"\x10actionLogSubtype\"7\n" +
"\x11ActionLogReaction\x12\"\n" +
- "\femojiUnicode\x18\x01 \x01(\tR\femojiUnicodeB9Z7go.mau.fi/whatsmeow/proto/instamadilloCoreTypeActionLog"
+ "\femojiUnicode\x18\x01 \x01(\tR\femojiUnicodeBDZBgithub.com/polymorfa/hypermeow/proto/instamadilloCoreTypeActionLog"
var (
file_instamadilloCoreTypeActionLog_InstamadilloCoreTypeActionLog_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto b/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto
index dad19791d..63725d23d 100644
--- a/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto
+++ b/proto/instamadilloCoreTypeActionLog/InstamadilloCoreTypeActionLog.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloCoreTypeActionLog;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeActionLog";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeActionLog";
message ActionLog {
oneof actionLogSubtype {
diff --git a/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.pb.go b/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.pb.go
index 0a0195fbb..538dce889 100644
--- a/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.pb.go
+++ b/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto
package instamadilloCoreTypeAdminMessage
@@ -219,7 +219,7 @@ const file_instamadilloCoreTypeAdminMessage_InstamadilloCoreTypeAdminMessage_pro
"\x1eDEVICE_ADMIN_MESSAGE_TYPE_NONE\x10\x00\x12J\n" +
"FDEVICE_ADMIN_MESSAGE_TYPE_LOCAL_USER_CHANGED_IDENTITY_KEY_NAMED_DEVICE\x10\x01\x12C\n" +
"?DEVICE_ADMIN_MESSAGE_TYPE_SECURITY_ALERT_PARTICIPANT_KEY_CHANGE\x10\x02\x12B\n" +
- ">DEVICE_ADMIN_MESSAGE_TYPE_SECURITY_ALERT_PARTICIPANT_NEW_LOGIN\x10\x03BDEVICE_ADMIN_MESSAGE_TYPE_SECURITY_ALERT_PARTICIPANT_NEW_LOGIN\x10\x03BGZEgithub.com/polymorfa/hypermeow/proto/instamadilloCoreTypeAdminMessage"
var (
file_instamadilloCoreTypeAdminMessage_InstamadilloCoreTypeAdminMessage_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto b/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto
index c6bb7e4e4..bcfac7bfb 100644
--- a/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto
+++ b/proto/instamadilloCoreTypeAdminMessage/InstamadilloCoreTypeAdminMessage.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloCoreTypeAdminMessage;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeAdminMessage";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeAdminMessage";
message AdminMessage {
oneof adminMessageSubtype {
diff --git a/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.pb.go b/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.pb.go
index 91c025eb0..228bf822e 100644
--- a/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.pb.go
+++ b/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto
package instamadilloCoreTypeCollection
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia"
+ instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia"
)
const (
@@ -84,7 +84,7 @@ const file_instamadilloCoreTypeCollection_InstamadilloCoreTypeCollection_proto_r
"\n" +
"Collection\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x126\n" +
- "\x05media\x18\x02 \x03(\v2 .InstamadilloCoreTypeMedia.MediaR\x05mediaB:Z8go.mau.fi/whatsmeow/proto/instamadilloCoreTypeCollection"
+ "\x05media\x18\x02 \x03(\v2 .InstamadilloCoreTypeMedia.MediaR\x05mediaBEZCgithub.com/polymorfa/hypermeow/proto/instamadilloCoreTypeCollection"
var (
file_instamadilloCoreTypeCollection_InstamadilloCoreTypeCollection_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto b/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto
index 4ad9f921a..760e584d9 100644
--- a/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto
+++ b/proto/instamadilloCoreTypeCollection/InstamadilloCoreTypeCollection.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloCoreTypeCollection;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeCollection";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeCollection";
import "instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto";
diff --git a/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.pb.go b/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.pb.go
index f888c6eb8..e6953effe 100644
--- a/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.pb.go
+++ b/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto
package instamadilloCoreTypeLink
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia"
+ instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia"
)
const (
@@ -256,7 +256,7 @@ const file_instamadilloCoreTypeLink_InstamadilloCoreTypeLink_proto_rawDesc = ""
"\bImageUrl\x12\x10\n" +
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x14\n" +
"\x05width\x18\x02 \x01(\x05R\x05width\x12\x16\n" +
- "\x06height\x18\x03 \x01(\x05R\x06heightB4Z2go.mau.fi/whatsmeow/proto/instamadilloCoreTypeLink"
+ "\x06height\x18\x03 \x01(\x05R\x06heightB?Z=github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeLink"
var (
file_instamadilloCoreTypeLink_InstamadilloCoreTypeLink_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto b/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto
index e7b66c6a6..b95ad7e76 100644
--- a/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto
+++ b/proto/instamadilloCoreTypeLink/InstamadilloCoreTypeLink.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloCoreTypeLink;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeLink";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeLink";
import "instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto";
diff --git a/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.pb.go b/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.pb.go
index 9dc5b4fa1..1c090bcad 100644
--- a/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.pb.go
+++ b/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto
package instamadilloCoreTypeMedia
@@ -1201,7 +1201,7 @@ const file_instamadilloCoreTypeMedia_InstamadilloCoreTypeMedia_proto_rawDesc = "
"$PJPEG_SCAN_CONFIGURATION_UNSPECIFIED\x10\x00\x12\x1f\n" +
"\x1bPJPEG_SCAN_CONFIGURATION_WA\x10\x01\x12 \n" +
"\x1cPJPEG_SCAN_CONFIGURATION_E15\x10\x02\x12 \n" +
- "\x1cPJPEG_SCAN_CONFIGURATION_E35\x10\x03B5Z3go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia"
+ "\x1cPJPEG_SCAN_CONFIGURATION_E35\x10\x03B@Z>github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia"
var (
file_instamadilloCoreTypeMedia_InstamadilloCoreTypeMedia_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto b/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto
index eae154790..ce7ca59c3 100644
--- a/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto
+++ b/proto/instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloCoreTypeMedia;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia";
enum PjpegScanConfiguration {
PJPEG_SCAN_CONFIGURATION_UNSPECIFIED = 0;
diff --git a/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.pb.go b/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.pb.go
index ef0180f81..4ff3bb4a1 100644
--- a/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.pb.go
+++ b/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloCoreTypeText/InstamadilloCoreTypeText.proto
package instamadilloCoreTypeText
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia"
+ instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia"
)
const (
@@ -450,7 +450,7 @@ const file_instamadilloCoreTypeText_InstamadilloCoreTypeText_proto_rawDesc = ""
"\x05style\x18\x03 \x01(\x0e2*.InstamadilloCoreTypeText.Text.FormatStyleR\x05style\"M\n" +
"\x1bAnimatedEmojiCharacterRange\x12\x16\n" +
"\x06offset\x18\x01 \x01(\x05R\x06offset\x12\x16\n" +
- "\x06length\x18\x02 \x01(\x05R\x06lengthB4Z2go.mau.fi/whatsmeow/proto/instamadilloCoreTypeText"
+ "\x06length\x18\x02 \x01(\x05R\x06lengthB?Z=github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeText"
var (
file_instamadilloCoreTypeText_InstamadilloCoreTypeText_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.proto b/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.proto
index 8c62e8d3a..503539dc7 100644
--- a/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.proto
+++ b/proto/instamadilloCoreTypeText/InstamadilloCoreTypeText.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloCoreTypeText;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeText";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeText";
import "instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto";
diff --git a/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.pb.go b/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.pb.go
index 5b732e6f0..a2d2e5a86 100644
--- a/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.pb.go
+++ b/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloDeleteMessage/InstamadilloDeleteMessage.proto
package instamadilloDeleteMessage
@@ -72,7 +72,7 @@ const file_instamadilloDeleteMessage_InstamadilloDeleteMessage_proto_rawDesc = "
"\n" +
"9instamadilloDeleteMessage/InstamadilloDeleteMessage.proto\x12\x19InstamadilloDeleteMessage\"8\n" +
"\x14DeleteMessagePayload\x12 \n" +
- "\vmessageOtid\x18\x01 \x01(\tR\vmessageOtidB5Z3go.mau.fi/whatsmeow/proto/instamadilloDeleteMessage"
+ "\vmessageOtid\x18\x01 \x01(\tR\vmessageOtidB@Z>github.com/polymorfa/hypermeow/proto/instamadilloDeleteMessage"
var (
file_instamadilloDeleteMessage_InstamadilloDeleteMessage_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.proto b/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.proto
index 0cd7a9ddc..045f3d0f1 100644
--- a/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.proto
+++ b/proto/instamadilloDeleteMessage/InstamadilloDeleteMessage.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloDeleteMessage;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloDeleteMessage";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloDeleteMessage";
message DeleteMessagePayload {
optional string messageOtid = 1;
diff --git a/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.pb.go b/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.pb.go
index be1b87a82..36bd4411c 100644
--- a/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.pb.go
+++ b/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloSupplementMessage/InstamadilloSupplementMessage.proto
package instamadilloSupplementMessage
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- instamadilloCoreTypeMedia "go.mau.fi/whatsmeow/proto/instamadilloCoreTypeMedia"
+ instamadilloCoreTypeMedia "github.com/polymorfa/hypermeow/proto/instamadilloCoreTypeMedia"
)
const (
@@ -644,7 +644,7 @@ const file_instamadilloSupplementMessage_InstamadilloSupplementMessage_proto_raw
"\x18originalTransportPayload\x18\x01 \x01(\fR\x18originalTransportPayload\"\x8d\x01\n" +
"\x12MediaInterventions\x12\x18\n" +
"\amediaID\x18\x01 \x01(\tR\amediaID\x12]\n" +
- "\x10interventionType\x18\x02 \x01(\x0e21.InstamadilloCoreTypeMedia.Media.InterventionTypeR\x10interventionTypeB9Z7go.mau.fi/whatsmeow/proto/instamadilloSupplementMessage"
+ "\x10interventionType\x18\x02 \x01(\x0e21.InstamadilloCoreTypeMedia.Media.InterventionTypeR\x10interventionTypeBDZBgithub.com/polymorfa/hypermeow/proto/instamadilloSupplementMessage"
var (
file_instamadilloSupplementMessage_InstamadilloSupplementMessage_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.proto b/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.proto
index 09ff068bc..751040b45 100644
--- a/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.proto
+++ b/proto/instamadilloSupplementMessage/InstamadilloSupplementMessage.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloSupplementMessage;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloSupplementMessage";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloSupplementMessage";
import "instamadilloCoreTypeMedia/InstamadilloCoreTypeMedia.proto";
diff --git a/proto/instamadilloTransportPayload/InstamadilloTransportPayload.pb.go b/proto/instamadilloTransportPayload/InstamadilloTransportPayload.pb.go
index 9ae3c72cc..aaae3d4dc 100644
--- a/proto/instamadilloTransportPayload/InstamadilloTransportPayload.pb.go
+++ b/proto/instamadilloTransportPayload/InstamadilloTransportPayload.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloTransportPayload/InstamadilloTransportPayload.proto
package instamadilloTransportPayload
@@ -14,9 +14,9 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- instamadilloAddMessage "go.mau.fi/whatsmeow/proto/instamadilloAddMessage"
- instamadilloDeleteMessage "go.mau.fi/whatsmeow/proto/instamadilloDeleteMessage"
- instamadilloSupplementMessage "go.mau.fi/whatsmeow/proto/instamadilloSupplementMessage"
+ instamadilloAddMessage "github.com/polymorfa/hypermeow/proto/instamadilloAddMessage"
+ instamadilloDeleteMessage "github.com/polymorfa/hypermeow/proto/instamadilloDeleteMessage"
+ instamadilloSupplementMessage "github.com/polymorfa/hypermeow/proto/instamadilloSupplementMessage"
)
const (
@@ -297,7 +297,7 @@ const file_instamadilloTransportPayload_InstamadilloTransportPayload_proto_rawDe
"\x15PAYLOAD_CREATOR_IGIOS\x10\x01\x12\x18\n" +
"\x14PAYLOAD_CREATOR_IG4A\x10\x02\x12\x17\n" +
"\x13PAYLOAD_CREATOR_WWW\x10\x03\x12\x1a\n" +
- "\x16PAYLOAD_CREATOR_IGLITE\x10\x04B8Z6go.mau.fi/whatsmeow/proto/instamadilloTransportPayload"
+ "\x16PAYLOAD_CREATOR_IGLITE\x10\x04BCZAgithub.com/polymorfa/hypermeow/proto/instamadilloTransportPayload"
var (
file_instamadilloTransportPayload_InstamadilloTransportPayload_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloTransportPayload/InstamadilloTransportPayload.proto b/proto/instamadilloTransportPayload/InstamadilloTransportPayload.proto
index 964f84576..24e26689b 100644
--- a/proto/instamadilloTransportPayload/InstamadilloTransportPayload.proto
+++ b/proto/instamadilloTransportPayload/InstamadilloTransportPayload.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloTransportPayload;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloTransportPayload";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloTransportPayload";
import "instamadilloAddMessage/InstamadilloAddMessage.proto";
import "instamadilloDeleteMessage/InstamadilloDeleteMessage.proto";
diff --git a/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.pb.go b/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.pb.go
index 67e54e5de..e7e4d37a2 100644
--- a/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.pb.go
+++ b/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: instamadilloXmaContentRef/InstamadilloXmaContentRef.proto
package instamadilloXmaContentRef
@@ -1142,7 +1142,7 @@ const file_instamadilloXmaContentRef_InstamadilloXmaContentRef_proto_rawDesc = "
"\x1fMediaNoteFetchParamsMessageType\x124\n" +
"0MEDIA_NOTE_FETCH_PARAMS_MESSAGE_TYPE_UNSPECIFIED\x10\x00\x120\n" +
",MEDIA_NOTE_FETCH_PARAMS_MESSAGE_TYPE_MENTION\x10\x01\x12.\n" +
- "*MEDIA_NOTE_FETCH_PARAMS_MESSAGE_TYPE_REPLY\x10\x02B5Z3go.mau.fi/whatsmeow/proto/instamadilloXmaContentRef"
+ "*MEDIA_NOTE_FETCH_PARAMS_MESSAGE_TYPE_REPLY\x10\x02B@Z>github.com/polymorfa/hypermeow/proto/instamadilloXmaContentRef"
var (
file_instamadilloXmaContentRef_InstamadilloXmaContentRef_proto_rawDescOnce sync.Once
diff --git a/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.proto b/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.proto
index 00bc9378d..5700144af 100644
--- a/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.proto
+++ b/proto/instamadilloXmaContentRef/InstamadilloXmaContentRef.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package InstamadilloXmaContentRef;
-option go_package = "go.mau.fi/whatsmeow/proto/instamadilloXmaContentRef";
+option go_package = "github.com/polymorfa/hypermeow/proto/instamadilloXmaContentRef";
enum XmaActionType {
XMA_ACTION_TYPE_UNSPECIFIED = 0;
diff --git a/proto/waAICommon/WAWebProtobufsAICommon.pb.go b/proto/waAICommon/WAWebProtobufsAICommon.pb.go
index fa69144ce..76dae1f5a 100644
--- a/proto/waAICommon/WAWebProtobufsAICommon.pb.go
+++ b/proto/waAICommon/WAWebProtobufsAICommon.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
-// protoc v7.34.1
+// protoc v6.33.6
// source: waAICommon/WAWebProtobufsAICommon.proto
package waAICommon
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waCommon "go.mau.fi/whatsmeow/proto/waCommon"
+ waCommon "github.com/polymorfa/hypermeow/proto/waCommon"
)
const (
@@ -8190,7 +8190,7 @@ const file_waAICommon_WAWebProtobufsAICommon_proto_rawDesc = "" +
"\tVIDEO_GEN\x10\x03*H\n" +
"\x17SessionTransparencyType\x12\x10\n" +
"\fUNKNOWN_TYPE\x10\x00\x12\x1b\n" +
- "\x17NY_AI_SAFETY_DISCLAIMER\x10\x01B&Z$go.mau.fi/whatsmeow/proto/waAICommon"
+ "\x17NY_AI_SAFETY_DISCLAIMER\x10\x01B1Z/github.com/polymorfa/hypermeow/proto/waAICommon"
var (
file_waAICommon_WAWebProtobufsAICommon_proto_rawDescOnce sync.Once
diff --git a/proto/waAICommon/WAWebProtobufsAICommon.proto b/proto/waAICommon/WAWebProtobufsAICommon.proto
index ae912e66c..d9fff91ca 100644
--- a/proto/waAICommon/WAWebProtobufsAICommon.proto
+++ b/proto/waAICommon/WAWebProtobufsAICommon.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAWebProtobufsAICommon;
-option go_package = "go.mau.fi/whatsmeow/proto/waAICommon";
+option go_package = "github.com/polymorfa/hypermeow/proto/waAICommon";
import "waCommon/WACommon.proto";
diff --git a/proto/waAICommonDeprecated/WAAICommonDeprecated.pb.go b/proto/waAICommonDeprecated/WAAICommonDeprecated.pb.go
index 2dd8390ad..ab545c7b7 100644
--- a/proto/waAICommonDeprecated/WAAICommonDeprecated.pb.go
+++ b/proto/waAICommonDeprecated/WAAICommonDeprecated.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
-// protoc v6.33.5
+// protoc v6.33.6
// source: waAICommonDeprecated/WAAICommonDeprecated.proto
package waAICommonDeprecated
@@ -1599,7 +1599,7 @@ const file_waAICommonDeprecated_WAAICommonDeprecated_proto_rawDesc = "" +
"\x18AI_RICH_RESPONSE_DYNAMIC\x10\x06\x12\x18\n" +
"\x14AI_RICH_RESPONSE_MAP\x10\a\x12\x1a\n" +
"\x16AI_RICH_RESPONSE_LATEX\x10\b\x12\"\n" +
- "\x1eAI_RICH_RESPONSE_CONTENT_ITEMS\x10\tB0Z.go.mau.fi/whatsmeow/proto/waAICommonDeprecated"
+ "\x1eAI_RICH_RESPONSE_CONTENT_ITEMS\x10\tB;Z9github.com/polymorfa/hypermeow/proto/waAICommonDeprecated"
var (
file_waAICommonDeprecated_WAAICommonDeprecated_proto_rawDescOnce sync.Once
diff --git a/proto/waAICommonDeprecated/WAAICommonDeprecated.proto b/proto/waAICommonDeprecated/WAAICommonDeprecated.proto
index 5412ac42e..6f7c4edb5 100644
--- a/proto/waAICommonDeprecated/WAAICommonDeprecated.proto
+++ b/proto/waAICommonDeprecated/WAAICommonDeprecated.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAAICommonDeprecated;
-option go_package = "go.mau.fi/whatsmeow/proto/waAICommonDeprecated";
+option go_package = "github.com/polymorfa/hypermeow/proto/waAICommonDeprecated";
enum AIRichResponseMessageType {
AI_RICH_RESPONSE_TYPE_UNKNOWN = 0;
diff --git a/proto/waAdv/WAAdv.pb.go b/proto/waAdv/WAAdv.pb.go
index 7e84d0a46..b53247518 100644
--- a/proto/waAdv/WAAdv.pb.go
+++ b/proto/waAdv/WAAdv.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waAdv/WAAdv.proto
package waAdv
@@ -453,7 +453,7 @@ const file_waAdv_WAAdv_proto_rawDesc = "" +
"\x11ADVEncryptionType\x12\b\n" +
"\x04E2EE\x10\x00\x12\n" +
"\n" +
- "\x06HOSTED\x10\x01B!Z\x1fgo.mau.fi/whatsmeow/proto/waAdv"
+ "\x06HOSTED\x10\x01B,Z*github.com/polymorfa/hypermeow/proto/waAdv"
var (
file_waAdv_WAAdv_proto_rawDescOnce sync.Once
diff --git a/proto/waAdv/WAAdv.proto b/proto/waAdv/WAAdv.proto
index 07c96c84b..c69b3b223 100644
--- a/proto/waAdv/WAAdv.proto
+++ b/proto/waAdv/WAAdv.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAAdv;
-option go_package = "go.mau.fi/whatsmeow/proto/waAdv";
+option go_package = "github.com/polymorfa/hypermeow/proto/waAdv";
enum ADVEncryptionType {
E2EE = 0;
diff --git a/proto/waAea/WAWebProtobufsAea.pb.go b/proto/waAea/WAWebProtobufsAea.pb.go
index 97543d516..c01636654 100644
--- a/proto/waAea/WAWebProtobufsAea.pb.go
+++ b/proto/waAea/WAWebProtobufsAea.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
-// protoc v7.34.1
+// protoc v6.33.6
// source: waAea/WAWebProtobufsAea.proto
package waAea
@@ -135,7 +135,7 @@ const file_waAea_WAWebProtobufsAea_proto_rawDesc = "" +
"\vAccountType\x12\b\n" +
"\x04E2EE\x10\x00\x12\x0f\n" +
"\vHYBRID_E2EE\x10\x01\x12\f\n" +
- "\bNON_E2EE\x10\x02B!Z\x1fgo.mau.fi/whatsmeow/proto/waAea"
+ "\bNON_E2EE\x10\x02B,Z*github.com/polymorfa/hypermeow/proto/waAea"
var (
file_waAea_WAWebProtobufsAea_proto_rawDescOnce sync.Once
diff --git a/proto/waAea/WAWebProtobufsAea.proto b/proto/waAea/WAWebProtobufsAea.proto
index 79a0f3749..dd60765a3 100644
--- a/proto/waAea/WAWebProtobufsAea.proto
+++ b/proto/waAea/WAWebProtobufsAea.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAWebProtobufsAea;
-option go_package = "go.mau.fi/whatsmeow/proto/waAea";
+option go_package = "github.com/polymorfa/hypermeow/proto/waAea";
message NonE2EEAttestation {
enum AccountType {
diff --git a/proto/waArmadilloApplication/WAArmadilloApplication.pb.go b/proto/waArmadilloApplication/WAArmadilloApplication.pb.go
index 88e33c570..c8b44e2e4 100644
--- a/proto/waArmadilloApplication/WAArmadilloApplication.pb.go
+++ b/proto/waArmadilloApplication/WAArmadilloApplication.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waArmadilloApplication/WAArmadilloApplication.proto
package waArmadilloApplication
@@ -14,8 +14,8 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waArmadilloXMA "go.mau.fi/whatsmeow/proto/waArmadilloXMA"
- waCommon "go.mau.fi/whatsmeow/proto/waCommon"
+ waArmadilloXMA "github.com/polymorfa/hypermeow/proto/waArmadilloXMA"
+ waCommon "github.com/polymorfa/hypermeow/proto/waCommon"
)
const (
@@ -3075,7 +3075,7 @@ const file_waArmadilloApplication_WAArmadilloApplication_proto_rawDesc = "" +
"\vMEDIUM_LIKE\x10\x02\x12\x0e\n" +
"\n" +
"LARGE_LIKE\x10\x03B\t\n" +
- "\acontentB2Z0go.mau.fi/whatsmeow/proto/waArmadilloApplication"
+ "\acontentB=Z;github.com/polymorfa/hypermeow/proto/waArmadilloApplication"
var (
file_waArmadilloApplication_WAArmadilloApplication_proto_rawDescOnce sync.Once
diff --git a/proto/waArmadilloApplication/WAArmadilloApplication.proto b/proto/waArmadilloApplication/WAArmadilloApplication.proto
index 02baef992..4d671d40f 100644
--- a/proto/waArmadilloApplication/WAArmadilloApplication.proto
+++ b/proto/waArmadilloApplication/WAArmadilloApplication.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAArmadilloApplication;
-option go_package = "go.mau.fi/whatsmeow/proto/waArmadilloApplication";
+option go_package = "github.com/polymorfa/hypermeow/proto/waArmadilloApplication";
import "waArmadilloXMA/WAArmadilloXMA.proto";
import "waCommon/WACommon.proto";
diff --git a/proto/waArmadilloApplication/extra.go b/proto/waArmadilloApplication/extra.go
index 4337d3809..5c653940e 100644
--- a/proto/waArmadilloApplication/extra.go
+++ b/proto/waArmadilloApplication/extra.go
@@ -1,9 +1,9 @@
package waArmadilloApplication
import (
- "go.mau.fi/whatsmeow/proto/armadilloutil"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waMediaTransport"
+ "github.com/polymorfa/hypermeow/proto/armadilloutil"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waMediaTransport"
)
func (*Armadillo) IsMessageApplicationSub() {}
diff --git a/proto/waArmadilloBackupCommon/WAArmadilloBackupCommon.pb.go b/proto/waArmadilloBackupCommon/WAArmadilloBackupCommon.pb.go
index 77a6d9042..06a4154be 100644
--- a/proto/waArmadilloBackupCommon/WAArmadilloBackupCommon.pb.go
+++ b/proto/waArmadilloBackupCommon/WAArmadilloBackupCommon.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
-// protoc v6.33.5
+// protoc v6.33.6
// source: waArmadilloBackupCommon/WAArmadilloBackupCommon.proto
package waArmadilloBackupCommon
@@ -245,7 +245,7 @@ const file_waArmadilloBackupCommon_WAArmadilloBackupCommon_proto_rawDesc = "" +
"\x0epayloadVersion\x18\x05 \x01(\x05R\x0epayloadVersion\x120\n" +
"\x13futureProofBehavior\x18\x06 \x01(\x05R\x13futureProofBehavior\x12$\n" +
"\rthreadTypeTag\x18\a \x01(\x05R\rthreadTypeTag\x12,\n" +
- "\x11clientTimestampMS\x18\b \x01(\x03R\x11clientTimestampMSB3Z1go.mau.fi/whatsmeow/proto/waArmadilloBackupCommon"
+ "\x11clientTimestampMS\x18\b \x01(\x03R\x11clientTimestampMSB>ZZgithub.com/polymorfa/hypermeow/proto/waArmadilloTransportEvent"
var (
file_waArmadilloTransportEvent_WAArmadilloTransportEvent_proto_rawDescOnce sync.Once
diff --git a/proto/waArmadilloTransportEvent/WAArmadilloTransportEvent.proto b/proto/waArmadilloTransportEvent/WAArmadilloTransportEvent.proto
index 192b2bcfc..9111e4d28 100644
--- a/proto/waArmadilloTransportEvent/WAArmadilloTransportEvent.proto
+++ b/proto/waArmadilloTransportEvent/WAArmadilloTransportEvent.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAArmadilloTransportEvent;
-option go_package = "go.mau.fi/whatsmeow/proto/waArmadilloTransportEvent";
+option go_package = "github.com/polymorfa/hypermeow/proto/waArmadilloTransportEvent";
message TransportEvent {
message Event {
diff --git a/proto/waArmadilloXMA/WAArmadilloXMA.pb.go b/proto/waArmadilloXMA/WAArmadilloXMA.pb.go
index 38e2caa48..59912e844 100644
--- a/proto/waArmadilloXMA/WAArmadilloXMA.pb.go
+++ b/proto/waArmadilloXMA/WAArmadilloXMA.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
-// protoc v6.33.5
+// protoc v6.33.6
// source: waArmadilloXMA/WAArmadilloXMA.proto
package waArmadilloXMA
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waCommon "go.mau.fi/whatsmeow/proto/waCommon"
+ waCommon "github.com/polymorfa/hypermeow/proto/waCommon"
)
const (
@@ -1096,7 +1096,7 @@ const file_waArmadilloXMA_WAArmadilloXMA_proto_rawDesc = "" +
"\x16RTC_ONGOING_AUDIO_CALL\x10\xc0\x17\x12\x1b\n" +
"\x16RTC_ONGOING_VIDEO_CALL\x10\xc1\x17\x12 \n" +
"\x1bMSG_RECEIVER_FETCH_FALLBACK\x10\xd1\x17\x12\x1a\n" +
- "\x15DATACLASS_SENDER_COPY\x10\xa0\x1fB*Z(go.mau.fi/whatsmeow/proto/waArmadilloXMA"
+ "\x15DATACLASS_SENDER_COPY\x10\xa0\x1fB5Z3github.com/polymorfa/hypermeow/proto/waArmadilloXMA"
var (
file_waArmadilloXMA_WAArmadilloXMA_proto_rawDescOnce sync.Once
diff --git a/proto/waArmadilloXMA/WAArmadilloXMA.proto b/proto/waArmadilloXMA/WAArmadilloXMA.proto
index c9a216f3f..9632cfc65 100644
--- a/proto/waArmadilloXMA/WAArmadilloXMA.proto
+++ b/proto/waArmadilloXMA/WAArmadilloXMA.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAArmadilloXMA;
-option go_package = "go.mau.fi/whatsmeow/proto/waArmadilloXMA";
+option go_package = "github.com/polymorfa/hypermeow/proto/waArmadilloXMA";
import "waCommon/WACommon.proto";
diff --git a/proto/waBotMetadata/WABotMetadata.pb.go b/proto/waBotMetadata/WABotMetadata.pb.go
index 311f564e4..f61eb4c34 100644
--- a/proto/waBotMetadata/WABotMetadata.pb.go
+++ b/proto/waBotMetadata/WABotMetadata.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waBotMetadata/WABotMetadata.proto
package waBotMetadata
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waCommon "go.mau.fi/whatsmeow/proto/waCommon"
+ waCommon "github.com/polymorfa/hypermeow/proto/waCommon"
)
const (
@@ -5057,7 +5057,7 @@ const file_waBotMetadata_WABotMetadata_proto_rawDesc = "" +
"USER_INPUT\x10\x03\x12\r\n" +
"\tEMU_FLASH\x10\x04\x12\x16\n" +
"\x12EMU_FLASH_FOLLOWUP\x10\x05\x12\t\n" +
- "\x05VOICE\x10\x06B)Z'go.mau.fi/whatsmeow/proto/waBotMetadata"
+ "\x05VOICE\x10\x06B4Z2github.com/polymorfa/hypermeow/proto/waBotMetadata"
var (
file_waBotMetadata_WABotMetadata_proto_rawDescOnce sync.Once
diff --git a/proto/waBotMetadata/WABotMetadata.proto b/proto/waBotMetadata/WABotMetadata.proto
index 0111b38a9..fd35e450a 100644
--- a/proto/waBotMetadata/WABotMetadata.proto
+++ b/proto/waBotMetadata/WABotMetadata.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WABotMetadata;
-option go_package = "go.mau.fi/whatsmeow/proto/waBotMetadata";
+option go_package = "github.com/polymorfa/hypermeow/proto/waBotMetadata";
import "waCommon/WACommon.proto";
diff --git a/proto/waCert/WACert.pb.go b/proto/waCert/WACert.pb.go
index e8c1200b1..b06d28d46 100644
--- a/proto/waCert/WACert.pb.go
+++ b/proto/waCert/WACert.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waCert/WACert.proto
package waCert
@@ -355,7 +355,7 @@ const file_waCert_WACert_proto_rawDesc = "" +
"\fissuerSerial\x18\x02 \x01(\rR\fissuerSerial\x12\x10\n" +
"\x03key\x18\x03 \x01(\fR\x03key\x12\x1c\n" +
"\tnotBefore\x18\x04 \x01(\x04R\tnotBefore\x12\x1a\n" +
- "\bnotAfter\x18\x05 \x01(\x04R\bnotAfterB\"Z go.mau.fi/whatsmeow/proto/waCert"
+ "\bnotAfter\x18\x05 \x01(\x04R\bnotAfterB-Z+github.com/polymorfa/hypermeow/proto/waCert"
var (
file_waCert_WACert_proto_rawDescOnce sync.Once
diff --git a/proto/waCert/WACert.proto b/proto/waCert/WACert.proto
index 1da8c8265..bfd34994a 100644
--- a/proto/waCert/WACert.proto
+++ b/proto/waCert/WACert.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WACert;
-option go_package = "go.mau.fi/whatsmeow/proto/waCert";
+option go_package = "github.com/polymorfa/hypermeow/proto/waCert";
message NoiseCertificate {
message Details {
diff --git a/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.pb.go b/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.pb.go
index fae5ba172..f47c8fb7c 100644
--- a/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.pb.go
+++ b/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waChatLockSettings/WAWebProtobufsChatLockSettings.proto
package waChatLockSettings
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waUserPassword "go.mau.fi/whatsmeow/proto/waUserPassword"
+ waUserPassword "github.com/polymorfa/hypermeow/proto/waUserPassword"
)
const (
@@ -85,7 +85,7 @@ const file_waChatLockSettings_WAWebProtobufsChatLockSettings_proto_rawDesc = ""
"\x0fhideLockedChats\x18\x01 \x01(\bR\x0fhideLockedChats\x12H\n" +
"\n" +
"secretCode\x18\x02 \x01(\v2(.WAWebProtobufsUserPassword.UserPasswordR\n" +
- "secretCodeB.Z,go.mau.fi/whatsmeow/proto/waChatLockSettings"
+ "secretCodeB9Z7github.com/polymorfa/hypermeow/proto/waChatLockSettings"
var (
file_waChatLockSettings_WAWebProtobufsChatLockSettings_proto_rawDescOnce sync.Once
diff --git a/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.proto b/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.proto
index 598be63fc..1516d4b16 100644
--- a/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.proto
+++ b/proto/waChatLockSettings/WAWebProtobufsChatLockSettings.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAWebProtobufsChatLockSettings;
-option go_package = "go.mau.fi/whatsmeow/proto/waChatLockSettings";
+option go_package = "github.com/polymorfa/hypermeow/proto/waChatLockSettings";
import "waUserPassword/WAWebProtobufsUserPassword.proto";
diff --git a/proto/waCommon/WACommon.pb.go b/proto/waCommon/WACommon.pb.go
index 2861ab14f..38afb79ca 100644
--- a/proto/waCommon/WACommon.pb.go
+++ b/proto/waCommon/WACommon.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
-// protoc v6.33.5
+// protoc v6.33.6
// source: waCommon/WACommon.proto
package waCommon
@@ -702,7 +702,7 @@ const file_waCommon_WACommon_proto_rawDesc = "" +
"\vPLACEHOLDER\x10\x00\x12\x12\n" +
"\x0eNO_PLACEHOLDER\x10\x01\x12\n" +
"\n" +
- "\x06IGNORE\x10\x02B$Z\"go.mau.fi/whatsmeow/proto/waCommon"
+ "\x06IGNORE\x10\x02B/Z-github.com/polymorfa/hypermeow/proto/waCommon"
var (
file_waCommon_WACommon_proto_rawDescOnce sync.Once
diff --git a/proto/waCommon/WACommon.proto b/proto/waCommon/WACommon.proto
index c75cd1748..b3ae309ca 100644
--- a/proto/waCommon/WACommon.proto
+++ b/proto/waCommon/WACommon.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WACommon;
-option go_package = "go.mau.fi/whatsmeow/proto/waCommon";
+option go_package = "github.com/polymorfa/hypermeow/proto/waCommon";
enum FutureProofBehavior {
PLACEHOLDER = 0;
diff --git a/proto/waCommonParameterised/WACommonParameterised.pb.go b/proto/waCommonParameterised/WACommonParameterised.pb.go
index 9d4162d7b..4bb84caab 100644
--- a/proto/waCommonParameterised/WACommonParameterised.pb.go
+++ b/proto/waCommonParameterised/WACommonParameterised.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waCommonParameterised/WACommonParameterised.proto
package waCommonParameterised
@@ -562,7 +562,7 @@ const file_waCommonParameterised_WACommonParameterised_proto_rawDesc = "" +
"\vPLACEHOLDER\x10\x00\x12\x12\n" +
"\x0eNO_PLACEHOLDER\x10\x01\x12\n" +
"\n" +
- "\x06IGNORE\x10\x02B1Z/go.mau.fi/whatsmeow/proto/waCommonParameterised"
+ "\x06IGNORE\x10\x02B\n" +
- "\bprotocol\x18\x01 \x01(\v2\".WACommonParameterised.SubProtocolR\bprotocolB>Zgithub.com/polymorfa/hypermeow/proto/waLidMigrationSyncPayload"
var (
file_waLidMigrationSyncPayload_WAWebProtobufLidMigrationSyncPayload_proto_rawDescOnce sync.Once
diff --git a/proto/waLidMigrationSyncPayload/WAWebProtobufLidMigrationSyncPayload.proto b/proto/waLidMigrationSyncPayload/WAWebProtobufLidMigrationSyncPayload.proto
index a0b044d14..7a308d5df 100644
--- a/proto/waLidMigrationSyncPayload/WAWebProtobufLidMigrationSyncPayload.proto
+++ b/proto/waLidMigrationSyncPayload/WAWebProtobufLidMigrationSyncPayload.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAWebProtobufLidMigrationSyncPayload;
-option go_package = "go.mau.fi/whatsmeow/proto/waLidMigrationSyncPayload";
+option go_package = "github.com/polymorfa/hypermeow/proto/waLidMigrationSyncPayload";
message LIDMigrationMapping {
required uint64 pn = 1;
diff --git a/proto/waMediaEntryData/WAMediaEntryData.pb.go b/proto/waMediaEntryData/WAMediaEntryData.pb.go
index ba14f146e..a82b89ab4 100644
--- a/proto/waMediaEntryData/WAMediaEntryData.pb.go
+++ b/proto/waMediaEntryData/WAMediaEntryData.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waMediaEntryData/WAMediaEntryData.proto
package waMediaEntryData
@@ -372,7 +372,7 @@ const file_waMediaEntryData_WAMediaEntryData_proto_rawDesc = "" +
"directPath\x12\x1a\n" +
"\bmediaKey\x18\x04 \x01(\fR\bmediaKey\x12,\n" +
"\x11mediaKeyTimestamp\x18\x05 \x01(\x03R\x11mediaKeyTimestamp\x12\x1a\n" +
- "\bobjectID\x18\x06 \x01(\tR\bobjectIDB,Z*go.mau.fi/whatsmeow/proto/waMediaEntryData"
+ "\bobjectID\x18\x06 \x01(\tR\bobjectIDB7Z5github.com/polymorfa/hypermeow/proto/waMediaEntryData"
var (
file_waMediaEntryData_WAMediaEntryData_proto_rawDescOnce sync.Once
diff --git a/proto/waMediaEntryData/WAMediaEntryData.proto b/proto/waMediaEntryData/WAMediaEntryData.proto
index a9419386a..1afb9fb38 100644
--- a/proto/waMediaEntryData/WAMediaEntryData.proto
+++ b/proto/waMediaEntryData/WAMediaEntryData.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAMediaEntryData;
-option go_package = "go.mau.fi/whatsmeow/proto/waMediaEntryData";
+option go_package = "github.com/polymorfa/hypermeow/proto/waMediaEntryData";
message MediaEntry {
message ProgressiveJpegDetails {
diff --git a/proto/waMediaTransport/WAMediaTransport.pb.go b/proto/waMediaTransport/WAMediaTransport.pb.go
index d79f20c3d..ff89aecad 100644
--- a/proto/waMediaTransport/WAMediaTransport.pb.go
+++ b/proto/waMediaTransport/WAMediaTransport.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waMediaTransport/WAMediaTransport.proto
package waMediaTransport
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waCommon "go.mau.fi/whatsmeow/proto/waCommon"
+ waCommon "github.com/polymorfa/hypermeow/proto/waCommon"
)
const (
@@ -2033,7 +2033,7 @@ const file_waMediaTransport_WAMediaTransport_proto_rawDesc = "" +
"\bIntegral\x12\x16\n" +
"\x05vcard\x18\x01 \x01(\tH\x00R\x05vcard\x12R\n" +
"\x11downloadableVcard\x18\x02 \x01(\v2\".WAMediaTransport.WAMediaTransportH\x00R\x11downloadableVcardB\t\n" +
- "\acontactB,Z*go.mau.fi/whatsmeow/proto/waMediaTransport"
+ "\acontactB7Z5github.com/polymorfa/hypermeow/proto/waMediaTransport"
var (
file_waMediaTransport_WAMediaTransport_proto_rawDescOnce sync.Once
diff --git a/proto/waMediaTransport/WAMediaTransport.proto b/proto/waMediaTransport/WAMediaTransport.proto
index 59ef7d955..ef2ada0a3 100644
--- a/proto/waMediaTransport/WAMediaTransport.proto
+++ b/proto/waMediaTransport/WAMediaTransport.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAMediaTransport;
-option go_package = "go.mau.fi/whatsmeow/proto/waMediaTransport";
+option go_package = "github.com/polymorfa/hypermeow/proto/waMediaTransport";
import "waCommon/WACommon.proto";
diff --git a/proto/waMmsRetry/WAMmsRetry.pb.go b/proto/waMmsRetry/WAMmsRetry.pb.go
index 6128a8090..da1b0a28c 100644
--- a/proto/waMmsRetry/WAMmsRetry.pb.go
+++ b/proto/waMmsRetry/WAMmsRetry.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waMmsRetry/WAMmsRetry.proto
package waMmsRetry
@@ -216,7 +216,7 @@ const file_waMmsRetry_WAMmsRetry_proto_rawDesc = "" +
"\tNOT_FOUND\x10\x02\x12\x14\n" +
"\x10DECRYPTION_ERROR\x10\x03\"0\n" +
"\x12ServerErrorReceipt\x12\x1a\n" +
- "\bstanzaID\x18\x01 \x01(\tR\bstanzaIDB&Z$go.mau.fi/whatsmeow/proto/waMmsRetry"
+ "\bstanzaID\x18\x01 \x01(\tR\bstanzaIDB1Z/github.com/polymorfa/hypermeow/proto/waMmsRetry"
var (
file_waMmsRetry_WAMmsRetry_proto_rawDescOnce sync.Once
diff --git a/proto/waMmsRetry/WAMmsRetry.proto b/proto/waMmsRetry/WAMmsRetry.proto
index c6b18610c..f9816e18a 100644
--- a/proto/waMmsRetry/WAMmsRetry.proto
+++ b/proto/waMmsRetry/WAMmsRetry.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAMmsRetry;
-option go_package = "go.mau.fi/whatsmeow/proto/waMmsRetry";
+option go_package = "github.com/polymorfa/hypermeow/proto/waMmsRetry";
message MediaRetryNotification {
enum ResultType {
diff --git a/proto/waMsgApplication/WAMsgApplication.pb.go b/proto/waMsgApplication/WAMsgApplication.pb.go
index b11c27870..09be071cc 100644
--- a/proto/waMsgApplication/WAMsgApplication.pb.go
+++ b/proto/waMsgApplication/WAMsgApplication.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waMsgApplication/WAMsgApplication.proto
package waMsgApplication
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waCommon "go.mau.fi/whatsmeow/proto/waCommon"
+ waCommon "github.com/polymorfa/hypermeow/proto/waCommon"
)
const (
@@ -1050,7 +1050,7 @@ const file_waMsgApplication_WAMsgApplication_proto_rawDesc = "" +
"\aUNKNOWN\x10\x00\x12\r\n" +
"\tSEEN_ONCE\x10\x01\x12\x19\n" +
"\x15SEEN_BASED_WITH_TIMER\x10\x02\x12\x19\n" +
- "\x15SEND_BASED_WITH_TIMER\x10\x03B,Z*go.mau.fi/whatsmeow/proto/waMsgApplication"
+ "\x15SEND_BASED_WITH_TIMER\x10\x03B7Z5github.com/polymorfa/hypermeow/proto/waMsgApplication"
var (
file_waMsgApplication_WAMsgApplication_proto_rawDescOnce sync.Once
diff --git a/proto/waMsgApplication/WAMsgApplication.proto b/proto/waMsgApplication/WAMsgApplication.proto
index 282e52734..9048b5b1e 100644
--- a/proto/waMsgApplication/WAMsgApplication.proto
+++ b/proto/waMsgApplication/WAMsgApplication.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAMsgApplication;
-option go_package = "go.mau.fi/whatsmeow/proto/waMsgApplication";
+option go_package = "github.com/polymorfa/hypermeow/proto/waMsgApplication";
import "waCommon/WACommon.proto";
diff --git a/proto/waMsgApplication/extra.go b/proto/waMsgApplication/extra.go
index b98f78100..b49a0faf2 100644
--- a/proto/waMsgApplication/extra.go
+++ b/proto/waMsgApplication/extra.go
@@ -1,10 +1,10 @@
package waMsgApplication
import (
- "go.mau.fi/whatsmeow/proto/armadilloutil"
- "go.mau.fi/whatsmeow/proto/waArmadilloApplication"
- "go.mau.fi/whatsmeow/proto/waConsumerApplication"
- "go.mau.fi/whatsmeow/proto/waMultiDevice"
+ "github.com/polymorfa/hypermeow/proto/armadilloutil"
+ "github.com/polymorfa/hypermeow/proto/waArmadilloApplication"
+ "github.com/polymorfa/hypermeow/proto/waConsumerApplication"
+ "github.com/polymorfa/hypermeow/proto/waMultiDevice"
)
const (
diff --git a/proto/waMsgTransport/WAMsgTransport.pb.go b/proto/waMsgTransport/WAMsgTransport.pb.go
index c0b2faf10..255a15536 100644
--- a/proto/waMsgTransport/WAMsgTransport.pb.go
+++ b/proto/waMsgTransport/WAMsgTransport.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waMsgTransport/WAMsgTransport.proto
package waMsgTransport
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waCommon "go.mau.fi/whatsmeow/proto/waCommon"
+ waCommon "github.com/polymorfa/hypermeow/proto/waCommon"
)
const (
@@ -779,7 +779,7 @@ const file_waMsgTransport_WAMsgTransport_proto_rawDesc = "" +
"\rsenderKeyHash\x18\x01 \x01(\fR\rsenderKeyHash\x12(\n" +
"\x0fsenderTimestamp\x18\x02 \x01(\x04R\x0fsenderTimestamp\x12*\n" +
"\x10recipientKeyHash\x18\b \x01(\fR\x10recipientKeyHash\x12.\n" +
- "\x12recipientTimestamp\x18\t \x01(\x04R\x12recipientTimestampB*Z(go.mau.fi/whatsmeow/proto/waMsgTransport"
+ "\x12recipientTimestamp\x18\t \x01(\x04R\x12recipientTimestampB5Z3github.com/polymorfa/hypermeow/proto/waMsgTransport"
var (
file_waMsgTransport_WAMsgTransport_proto_rawDescOnce sync.Once
diff --git a/proto/waMsgTransport/WAMsgTransport.proto b/proto/waMsgTransport/WAMsgTransport.proto
index fb5bfdadd..e2465e921 100644
--- a/proto/waMsgTransport/WAMsgTransport.proto
+++ b/proto/waMsgTransport/WAMsgTransport.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAMsgTransport;
-option go_package = "go.mau.fi/whatsmeow/proto/waMsgTransport";
+option go_package = "github.com/polymorfa/hypermeow/proto/waMsgTransport";
import "waCommon/WACommon.proto";
diff --git a/proto/waMsgTransport/extra.go b/proto/waMsgTransport/extra.go
index d8758f1e7..412b35c6e 100644
--- a/proto/waMsgTransport/extra.go
+++ b/proto/waMsgTransport/extra.go
@@ -1,9 +1,9 @@
package waMsgTransport
import (
- "go.mau.fi/whatsmeow/proto/armadilloutil"
- "go.mau.fi/whatsmeow/proto/instamadilloTransportPayload"
- "go.mau.fi/whatsmeow/proto/waMsgApplication"
+ "github.com/polymorfa/hypermeow/proto/armadilloutil"
+ "github.com/polymorfa/hypermeow/proto/instamadilloTransportPayload"
+ "github.com/polymorfa/hypermeow/proto/waMsgApplication"
)
const (
diff --git a/proto/waMultiDevice/WAMultiDevice.pb.go b/proto/waMultiDevice/WAMultiDevice.pb.go
index 17691d4b4..b93c322a7 100644
--- a/proto/waMultiDevice/WAMultiDevice.pb.go
+++ b/proto/waMultiDevice/WAMultiDevice.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waMultiDevice/WAMultiDevice.proto
package waMultiDevice
@@ -652,7 +652,7 @@ const file_waMultiDevice_WAMultiDevice_proto_rawDesc = "" +
"\x11AppStateSyncKeyId\x12\x14\n" +
"\x05keyID\x18\x01 \x01(\fR\x05keyIDB\x11\n" +
"\x0fapplicationData\x1a\b\n" +
- "\x06SignalB)Z'go.mau.fi/whatsmeow/proto/waMultiDevice"
+ "\x06SignalB4Z2github.com/polymorfa/hypermeow/proto/waMultiDevice"
var (
file_waMultiDevice_WAMultiDevice_proto_rawDescOnce sync.Once
diff --git a/proto/waMultiDevice/WAMultiDevice.proto b/proto/waMultiDevice/WAMultiDevice.proto
index 3ddc23088..d3d55d658 100644
--- a/proto/waMultiDevice/WAMultiDevice.proto
+++ b/proto/waMultiDevice/WAMultiDevice.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAMultiDevice;
-option go_package = "go.mau.fi/whatsmeow/proto/waMultiDevice";
+option go_package = "github.com/polymorfa/hypermeow/proto/waMultiDevice";
message MultiDevice {
message Metadata {
diff --git a/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.pb.go b/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.pb.go
index 2837eb66e..8eb24507b 100644
--- a/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.pb.go
+++ b/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto
package waQuickPromotionSurfaces
@@ -447,7 +447,7 @@ const file_waQuickPromotionSurfaces_WAWebProtobufsQuickPromotionSurfaces_proto_r
"ClauseType\x12\a\n" +
"\x03AND\x10\x01\x12\x06\n" +
"\x02OR\x10\x02\x12\a\n" +
- "\x03NOR\x10\x03B4Z2go.mau.fi/whatsmeow/proto/waQuickPromotionSurfaces"
+ "\x03NOR\x10\x03B?Z=github.com/polymorfa/hypermeow/proto/waQuickPromotionSurfaces"
var (
file_waQuickPromotionSurfaces_WAWebProtobufsQuickPromotionSurfaces_proto_rawDescOnce sync.Once
diff --git a/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto b/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto
index 4fb264325..cf522cd18 100644
--- a/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto
+++ b/proto/waQuickPromotionSurfaces/WAWebProtobufsQuickPromotionSurfaces.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAWebProtobufsQuickPromotionSurfaces;
-option go_package = "go.mau.fi/whatsmeow/proto/waQuickPromotionSurfaces";
+option go_package = "github.com/polymorfa/hypermeow/proto/waQuickPromotionSurfaces";
message QP {
enum FilterResult {
diff --git a/proto/waReporting/WAWebProtobufsReporting.pb.go b/proto/waReporting/WAWebProtobufsReporting.pb.go
index ef680f7ba..ddcd4fac8 100644
--- a/proto/waReporting/WAWebProtobufsReporting.pb.go
+++ b/proto/waReporting/WAWebProtobufsReporting.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waReporting/WAWebProtobufsReporting.proto
package waReporting
@@ -252,7 +252,7 @@ const file_waReporting_WAWebProtobufsReporting_proto_rawDesc = "" +
"\bsubfield\x18\x05 \x03(\v2,.WAWebProtobufsReporting.Field.SubfieldEntryR\bsubfield\x1a[\n" +
"\rSubfieldEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\rR\x03key\x124\n" +
- "\x05value\x18\x02 \x01(\v2\x1e.WAWebProtobufsReporting.FieldR\x05value:\x028\x01B'Z%go.mau.fi/whatsmeow/proto/waReportingb\x06proto3"
+ "\x05value\x18\x02 \x01(\v2\x1e.WAWebProtobufsReporting.FieldR\x05value:\x028\x01B2Z0github.com/polymorfa/hypermeow/proto/waReportingb\x06proto3"
var (
file_waReporting_WAWebProtobufsReporting_proto_rawDescOnce sync.Once
diff --git a/proto/waReporting/WAWebProtobufsReporting.proto b/proto/waReporting/WAWebProtobufsReporting.proto
index 598dadd1d..2f74b969b 100644
--- a/proto/waReporting/WAWebProtobufsReporting.proto
+++ b/proto/waReporting/WAWebProtobufsReporting.proto
@@ -1,6 +1,6 @@
syntax = "proto3";
package WAWebProtobufsReporting;
-option go_package = "go.mau.fi/whatsmeow/proto/waReporting";
+option go_package = "github.com/polymorfa/hypermeow/proto/waReporting";
message Reportable {
uint32 minVersion = 1;
diff --git a/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.pb.go b/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.pb.go
index 7f077987e..7e8e80f93 100644
--- a/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.pb.go
+++ b/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waRoutingInfo/WAWebProtobufsRoutingInfo.proto
package waRoutingInfo
@@ -117,7 +117,7 @@ const file_waRoutingInfo_WAWebProtobufsRoutingInfo_proto_rawDesc = "" +
"\x06taskID\x18\x03 \x01(\x05R\x06taskID\x12\x14\n" +
"\x05debug\x18\x04 \x01(\bR\x05debug\x12\x16\n" +
"\x06tcpBbr\x18\x05 \x01(\bR\x06tcpBbr\x12\"\n" +
- "\ftcpKeepalive\x18\x06 \x01(\bR\ftcpKeepaliveB)Z'go.mau.fi/whatsmeow/proto/waRoutingInfo"
+ "\ftcpKeepalive\x18\x06 \x01(\bR\ftcpKeepaliveB4Z2github.com/polymorfa/hypermeow/proto/waRoutingInfo"
var (
file_waRoutingInfo_WAWebProtobufsRoutingInfo_proto_rawDescOnce sync.Once
diff --git a/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.proto b/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.proto
index 3885d0a72..8ed63255e 100644
--- a/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.proto
+++ b/proto/waRoutingInfo/WAWebProtobufsRoutingInfo.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAWebProtobufsRoutingInfo;
-option go_package = "go.mau.fi/whatsmeow/proto/waRoutingInfo";
+option go_package = "github.com/polymorfa/hypermeow/proto/waRoutingInfo";
message RoutingInfo {
repeated int32 regionID = 1;
diff --git a/proto/waServerSync/WAWebProtobufsServerSync.pb.go b/proto/waServerSync/WAWebProtobufsServerSync.pb.go
index 41d74c95e..de9a4bcf3 100644
--- a/proto/waServerSync/WAWebProtobufsServerSync.pb.go
+++ b/proto/waServerSync/WAWebProtobufsServerSync.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
-// protoc v7.34.1
+// protoc v6.33.6
// source: waServerSync/WAWebProtobufsServerSync.proto
package waServerSync
@@ -955,7 +955,7 @@ const file_waServerSync_WAWebProtobufsServerSync_proto_rawDesc = "" +
"\x05index\x18\x01 \x01(\v2$.WAWebProtobufsServerSync.SyncdIndexR\x05index\x12:\n" +
"\x05value\x18\x02 \x01(\v2$.WAWebProtobufsServerSync.SyncdValueR\x05value\x12\"\n" +
"\fdirtyVersion\x18\x03 \x01(\x04R\fdirtyVersion\x12T\n" +
- "\toperation\x18\x04 \x01(\x0e26.WAWebProtobufsServerSync.SyncdMutation.SyncdOperationR\toperationB(Z&go.mau.fi/whatsmeow/proto/waServerSync"
+ "\toperation\x18\x04 \x01(\x0e26.WAWebProtobufsServerSync.SyncdMutation.SyncdOperationR\toperationB3Z1github.com/polymorfa/hypermeow/proto/waServerSync"
var (
file_waServerSync_WAWebProtobufsServerSync_proto_rawDescOnce sync.Once
diff --git a/proto/waServerSync/WAWebProtobufsServerSync.proto b/proto/waServerSync/WAWebProtobufsServerSync.proto
index efddc20a5..195e01792 100644
--- a/proto/waServerSync/WAWebProtobufsServerSync.proto
+++ b/proto/waServerSync/WAWebProtobufsServerSync.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAWebProtobufsServerSync;
-option go_package = "go.mau.fi/whatsmeow/proto/waServerSync";
+option go_package = "github.com/polymorfa/hypermeow/proto/waServerSync";
message SyncdMutation {
enum SyncdOperation {
diff --git a/proto/waStatusAttributions/WAStatusAttributions.pb.go b/proto/waStatusAttributions/WAStatusAttributions.pb.go
index 2badbbd0c..12f879583 100644
--- a/proto/waStatusAttributions/WAStatusAttributions.pb.go
+++ b/proto/waStatusAttributions/WAStatusAttributions.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
-// protoc v6.33.5
+// protoc v6.33.6
// source: waStatusAttributions/WAStatusAttributions.proto
package waStatusAttributions
@@ -1029,7 +1029,7 @@ const file_waStatusAttributions_WAStatusAttributions_proto_rawDesc = "" +
"\x11NEWSLETTER_STATUS\x10\t\x12\x18\n" +
"\x14STATUS_CLOSE_SHARING\x10\n" +
"B\x11\n" +
- "\x0fattributionDataB0Z.go.mau.fi/whatsmeow/proto/waStatusAttributions"
+ "\x0fattributionDataB;Z9github.com/polymorfa/hypermeow/proto/waStatusAttributions"
var (
file_waStatusAttributions_WAStatusAttributions_proto_rawDescOnce sync.Once
diff --git a/proto/waStatusAttributions/WAStatusAttributions.proto b/proto/waStatusAttributions/WAStatusAttributions.proto
index 8d61e137a..7a05aeed3 100644
--- a/proto/waStatusAttributions/WAStatusAttributions.proto
+++ b/proto/waStatusAttributions/WAStatusAttributions.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAStatusAttributions;
-option go_package = "go.mau.fi/whatsmeow/proto/waStatusAttributions";
+option go_package = "github.com/polymorfa/hypermeow/proto/waStatusAttributions";
message StatusAttribution {
enum Type {
diff --git a/proto/waSyncAction/WAWebProtobufSyncAction.pb.go b/proto/waSyncAction/WAWebProtobufSyncAction.pb.go
index 7507ace57..e3ac4bac5 100644
--- a/proto/waSyncAction/WAWebProtobufSyncAction.pb.go
+++ b/proto/waSyncAction/WAWebProtobufSyncAction.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
-// protoc v7.34.1
+// protoc v6.33.6
// source: waSyncAction/WAWebProtobufSyncAction.proto
package waSyncAction
@@ -14,9 +14,9 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waChatLockSettings "go.mau.fi/whatsmeow/proto/waChatLockSettings"
- waCommon "go.mau.fi/whatsmeow/proto/waCommon"
- waDeviceCapabilities "go.mau.fi/whatsmeow/proto/waDeviceCapabilities"
+ waChatLockSettings "github.com/polymorfa/hypermeow/proto/waChatLockSettings"
+ waCommon "github.com/polymorfa/hypermeow/proto/waCommon"
+ waDeviceCapabilities "github.com/polymorfa/hypermeow/proto/waDeviceCapabilities"
)
const (
@@ -9240,7 +9240,7 @@ const file_waSyncAction_WAWebProtobufSyncAction_proto_rawDesc = "" +
"PROCESSING\x10\x03\x12\n" +
"\n" +
"\x06FAILED\x10\x04\x12\b\n" +
- "\x04SENT\x10\x05B(Z&go.mau.fi/whatsmeow/proto/waSyncAction"
+ "\x04SENT\x10\x05B3Z1github.com/polymorfa/hypermeow/proto/waSyncAction"
var (
file_waSyncAction_WAWebProtobufSyncAction_proto_rawDescOnce sync.Once
diff --git a/proto/waSyncAction/WAWebProtobufSyncAction.proto b/proto/waSyncAction/WAWebProtobufSyncAction.proto
index dc5c136f7..c0616c371 100644
--- a/proto/waSyncAction/WAWebProtobufSyncAction.proto
+++ b/proto/waSyncAction/WAWebProtobufSyncAction.proto
@@ -1,6 +1,6 @@
syntax = "proto2";
package WAWebProtobufSyncAction;
-option go_package = "go.mau.fi/whatsmeow/proto/waSyncAction";
+option go_package = "github.com/polymorfa/hypermeow/proto/waSyncAction";
import "waChatLockSettings/WAWebProtobufsChatLockSettings.proto";
import "waDeviceCapabilities/WAWebProtobufsDeviceCapabilities.proto";
diff --git a/proto/waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.pb.go b/proto/waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.pb.go
index 853a48ce0..126f50d81 100644
--- a/proto/waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.pb.go
+++ b/proto/waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.pb.go
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
-// protoc-gen-go v1.36.10
-// protoc v3.21.12
+// protoc-gen-go v1.36.11
+// protoc v6.33.6
// source: waSyncdSnapshotRecovery/WAWebProtobufsSyncdSnapshotRecovery.proto
package waSyncdSnapshotRecovery
@@ -14,7 +14,7 @@ import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- waSyncAction "go.mau.fi/whatsmeow/proto/waSyncAction"
+ waSyncAction "github.com/polymorfa/hypermeow/proto/waSyncAction"
)
const (
@@ -211,7 +211,7 @@ const file_waSyncdSnapshotRecovery_WAWebProtobufsSyncdSnapshotRecovery_proto_raw
"\x05keyID\x18\x02 \x01(\fR\x05keyID\x12\x10\n" +
"\x03mac\x18\x03 \x01(\fR\x03mac\"(\n" +
"\fSyncdVersion\x12\x18\n" +
- "\aversion\x18\x01 \x01(\x04R\aversionB3Z1go.mau.fi/whatsmeow/proto/waSyncdSnapshotRecovery"
+ "\aversion\x18\x01 \x01(\x04R\aversionB>Z 0 {
+ responses, fetchErr := cli.fetchPreKeys(ctx, missing)
+ if fetchErr != nil {
+ return nil, fetchErr
+ }
+ for _, device := range missing {
+ response, exists := responses[device]
+ if !exists {
+ return nil, fmt.Errorf("identity key missing from prekey response for %s", device)
+ }
+ if response.err != nil {
+ return nil, fmt.Errorf("fetch identity key for %s: %w", device, response.err)
+ }
+ key := response.bundle.IdentityKey().PublicKey().PublicKey()
+ address := device.SignalAddress().String()
+ trusted, trustErr := reader.EnsureIdentity(ctx, address, key, deleteGeneration)
+ if trustErr != nil {
+ return nil, fmt.Errorf("ensure identity key for %s: %w", device, trustErr)
+ }
+ if !trusted {
+ return nil, fmt.Errorf("identity key for %s is not trusted", device)
+ }
+ stored[address] = key
+ }
+ }
+ keys := make([][32]byte, 0, len(addresses))
+ for _, address := range addresses {
+ key, exists := stored[address]
+ if !exists {
+ return nil, fmt.Errorf("identity key unavailable for %s", deviceByAddress[address])
+ }
+ keys = append(keys, key)
+ }
+ return keys, nil
+}
+
+func generateNumericSecurityCode(
+ ctx context.Context,
+ localIdentifier []byte,
+ localKeys [][32]byte,
+ remoteIdentifier []byte,
+ remoteKeys [][32]byte,
+) (string, error) {
+ local, err := generateSecurityCodeFingerprint(ctx, localIdentifier, serializeIdentityKeys(localKeys))
+ if err != nil {
+ return "", err
+ }
+ remote, err := generateSecurityCodeFingerprint(ctx, remoteIdentifier, serializeIdentityKeys(remoteKeys))
+ if err != nil {
+ return "", err
+ }
+ if local < remote {
+ return local + remote, nil
+ }
+ return remote + local, nil
+}
+
+func serializeIdentityKeys(keys [][32]byte) []byte {
+ keys = slices.Clone(keys)
+ slices.SortFunc(keys, func(left, right [32]byte) int {
+ return bytes.Compare(left[:], right[:])
+ })
+ serialized := make([]byte, 0, len(keys)*33)
+ for _, key := range keys {
+ serialized = append(serialized, 0x05)
+ serialized = append(serialized, key[:]...)
+ }
+ return serialized
+}
+
+func buildIdentityVerificationQRCodes(local, remote identityVerificationFingerprint) ([]byte, []byte, error) {
+ if err := validateIdentityVerificationFingerprint(local); err != nil {
+ return nil, nil, fmt.Errorf("invalid local fingerprint: %w", err)
+ }
+ if err := validateIdentityVerificationFingerprint(remote); err != nil {
+ return nil, nil, fmt.Errorf("invalid remote fingerprint: %w", err)
+ }
+ display, err := marshalCombinedFingerprint(local, remote, false)
+ if err != nil {
+ return nil, nil, err
+ }
+ verify, err := marshalCombinedFingerprint(local, remote, true)
+ if err != nil {
+ return nil, nil, err
+ }
+ return display, verify, nil
+}
+
+func validateIdentityVerificationFingerprint(fingerprint identityVerificationFingerprint) error {
+ if fingerprint.LID.Server != types.HiddenUserServer || fingerprint.LID.User == "" {
+ return errors.New("LID is required")
+ }
+ if !fingerprint.Phone.IsEmpty() && fingerprint.Phone.Server != types.DefaultUserServer {
+ return errors.New("phone number must be a phone-number JID")
+ }
+ if len(fingerprint.Keys) == 0 {
+ return errors.New("at least one identity key is required")
+ }
+ return nil
+}
+
+func marshalCombinedFingerprint(local, remote identityVerificationFingerprint, includeUnhashed bool) ([]byte, error) {
+ return proto.Marshal(&waFingerprint.CombinedFingerprint{
+ Version: proto.Uint32(1),
+ LocalFingerprint: buildFingerprintData(local, includeUnhashed),
+ RemoteFingerprint: buildFingerprintData(remote, includeUnhashed),
+ })
+}
+
+func buildFingerprintData(fingerprint identityVerificationFingerprint, includeUnhashed bool) *waFingerprint.FingerprintData {
+ serialized := serializeIdentityKeys(fingerprint.Keys)
+ hash := sha512.Sum512(serialized)
+ hostedState := waFingerprint.HostedState_E2EE
+ if fingerprint.Hosted {
+ hostedState = waFingerprint.HostedState_HOSTED
+ }
+ data := &waFingerprint.FingerprintData{
+ LidIdentifier: []byte(fingerprint.LID.String()),
+ UsernameIdentifier: []byte(fingerprint.Username),
+ HostedState: &hostedState,
+ HashedPublicKey: hash[:],
+ }
+ if !fingerprint.Phone.IsEmpty() {
+ data.PnIdentifier = []byte(fingerprint.Phone.User)
+ }
+ if includeUnhashed {
+ data.PublicKey = serialized
+ }
+ return data
+}
+
+func generateSecurityCodeFingerprint(ctx context.Context, identifier, keys []byte) (string, error) {
+ if err := ctx.Err(); err != nil {
+ return "", err
+ }
+ input := make([]byte, 2, 2+len(keys)+len(identifier))
+ input = append(input, keys...)
+ input = append(input, identifier...)
+ digest := sha512.Sum512(input)
+ input = make([]byte, 0, len(digest)+len(keys))
+ for index := 0; index < securityCodeIterations; index++ {
+ if index%64 == 0 {
+ if err := ctx.Err(); err != nil {
+ return "", err
+ }
+ }
+ input = append(input[:0], digest[:]...)
+ input = append(input, keys...)
+ digest = sha512.Sum512(input)
+ }
+ code := make([]byte, 0, 30)
+ for offset := 0; offset < 30; offset += 5 {
+ value := uint64(digest[offset])<<32 |
+ uint64(digest[offset+1])<<24 |
+ uint64(digest[offset+2])<<16 |
+ uint64(digest[offset+3])<<8 |
+ uint64(digest[offset+4])
+ code = fmt.Appendf(code, "%05d", value%100000)
+ }
+ return string(code), nil
+}
diff --git a/security_code_test.go b/security_code_test.go
new file mode 100644
index 000000000..91b2aa338
--- /dev/null
+++ b/security_code_test.go
@@ -0,0 +1,320 @@
+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.go b/send.go
index 757bfbce1..0a01b2733 100644
--- a/send.go
+++ b/send.go
@@ -29,12 +29,12 @@ import (
"go.mau.fi/util/random"
"google.golang.org/protobuf/proto"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waAICommon"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waAICommon"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
const WebMessageIDPrefix = "3EB0"
@@ -126,6 +126,14 @@ type SendResponse struct {
// The identity the message was sent with (LID or PN)
// This is currently not reliable in all cases.
Sender types.JID
+
+ // PHashMismatch indicates the server acknowledged a different participant list hash.
+ PHashMismatch bool
+}
+
+func setParticipantHashMismatch(resp *SendResponse, sent, acknowledged string) bool {
+ resp.PHashMismatch = acknowledged != "" && sent != acknowledged
+ return resp.PHashMismatch
}
// SendRequestExtra contains the optional parameters for SendMessage.
@@ -329,21 +337,10 @@ func (cli *Client) SendMessage(ctx context.Context, to types.JID, message *waE2E
} else if to.Server == types.DefaultUserServer && !req.Peer {
start := time.Now()
var toLID types.JID
- toLID, err = cli.Store.LIDs.GetLIDForPN(ctx, to)
+ toLID, err = cli.ResolveLID(ctx, to)
if err != nil {
- err = fmt.Errorf("failed to get LID for PN %s: %w", to, err)
+ err = fmt.Errorf("failed to resolve LID for PN %s: %w", to, err)
return
- } else if toLID.IsEmpty() {
- var info map[types.JID]types.UserInfo
- cli.Log.Debugf("LID for %s not found, fetching user info", to)
- info, err = cli.GetUserInfo(ctx, []types.JID{to})
- if err != nil {
- err = fmt.Errorf("failed to get user info for %s to fill LID cache: %w", to, err)
- return
- } else if toLID = info[to].LID; toLID.IsEmpty() {
- err = fmt.Errorf("no LID found for %s from server", to)
- return
- }
}
resp.DebugTimings.LIDFetch = time.Since(start)
cli.Log.Debugf("Replacing SendMessage destination with LID %s -> %s", to, toLID)
@@ -452,7 +449,7 @@ func (cli *Client) SendMessage(ctx context.Context, to types.JID, message *waE2E
err = fmt.Errorf("%w %d", ErrServerReturnedError, errorCode)
}
expectedPHash := ag.OptionalString("phash")
- if len(expectedPHash) > 0 && phash != expectedPHash {
+ if setParticipantHashMismatch(&resp, phash, expectedPHash) {
cli.Log.Warnf("Server returned different participant list hash (%s != %s) when sending to %s. Some devices may not have received the message.", phash, expectedPHash, to)
switch to.Server {
case types.GroupServer:
@@ -993,16 +990,16 @@ func getButtonTypeFromMessage(msg *waE2E.Message) string {
return getButtonTypeFromMessage(msg.ViewOnceMessage.Message)
case msg.ViewOnceMessageV2 != nil:
return getButtonTypeFromMessage(msg.ViewOnceMessageV2.Message)
+ case msg.ViewOnceMessageV2Extension != nil:
+ return getButtonTypeFromMessage(msg.ViewOnceMessageV2Extension.Message)
case msg.EphemeralMessage != nil:
return getButtonTypeFromMessage(msg.EphemeralMessage.Message)
case msg.ButtonsMessage != nil:
return "buttons"
- case msg.ButtonsResponseMessage != nil:
- return "buttons_response"
case msg.ListMessage != nil:
return "list"
- case msg.ListResponseMessage != nil:
- return "list_response"
+ case msg.InteractiveMessage != nil && msg.InteractiveMessage.GetNativeFlowMessage() != nil:
+ return "native_flow"
case msg.InteractiveResponseMessage != nil:
return "interactive_response"
default:
@@ -1010,12 +1007,57 @@ func getButtonTypeFromMessage(msg *waE2E.Message) string {
}
}
+func buildNativeFlowBizNode(msg *waE2E.Message, nowUnix int64) waBinary.Node {
+ name := "mixed"
+ for msg != nil {
+ switch {
+ case msg.ViewOnceMessage != nil:
+ msg = msg.ViewOnceMessage.Message
+ case msg.ViewOnceMessageV2 != nil:
+ msg = msg.ViewOnceMessageV2.Message
+ case msg.ViewOnceMessageV2Extension != nil:
+ msg = msg.ViewOnceMessageV2Extension.Message
+ case msg.EphemeralMessage != nil:
+ msg = msg.EphemeralMessage.Message
+ default:
+ goto unwrapped
+ }
+ }
+unwrapped:
+ if interactive := msg.GetInteractiveMessage(); interactive != nil {
+ if buttons := interactive.GetNativeFlowMessage().GetButtons(); len(buttons) > 0 && buttons[0].GetName() != "" {
+ candidate := buttons[0].GetName()
+ name = candidate
+ for _, button := range buttons[1:] {
+ if button.GetName() != candidate {
+ name = "mixed"
+ break
+ }
+ }
+ }
+ }
+ return waBinary.Node{
+ Tag: "biz",
+ Attrs: waBinary.Attrs{
+ "actual_actors": "2",
+ "host_storage": "2",
+ "privacy_mode_ts": strconv.FormatInt(nowUnix, 10),
+ },
+ Content: []waBinary.Node{
+ {Tag: "interactive", Attrs: waBinary.Attrs{"type": "native_flow", "v": "1"}, Content: []waBinary.Node{{Tag: "native_flow", Attrs: waBinary.Attrs{"name": name, "v": "9"}}}},
+ {Tag: "quality_control", Attrs: waBinary.Attrs{"source_type": "third_party"}},
+ },
+ }
+}
+
func getButtonAttributes(msg *waE2E.Message) waBinary.Attrs {
switch {
case msg.ViewOnceMessage != nil:
return getButtonAttributes(msg.ViewOnceMessage.Message)
case msg.ViewOnceMessageV2 != nil:
return getButtonAttributes(msg.ViewOnceMessageV2.Message)
+ case msg.ViewOnceMessageV2Extension != nil:
+ return getButtonAttributes(msg.ViewOnceMessageV2Extension.Message)
case msg.EphemeralMessage != nil:
return getButtonAttributes(msg.EphemeralMessage.Message)
case msg.TemplateMessage != nil:
@@ -1149,13 +1191,17 @@ func (cli *Client) getMessageContent(
}
if buttonType := getButtonTypeFromMessage(message); buttonType != "" {
- content = append(content, waBinary.Node{
- Tag: "biz",
- Content: []waBinary.Node{{
- Tag: buttonType,
- Attrs: getButtonAttributes(message),
- }},
- })
+ if buttonType == "native_flow" {
+ content = append(content, buildNativeFlowBizNode(message, time.Now().Unix()))
+ } else {
+ content = append(content, waBinary.Node{
+ Tag: "biz",
+ Content: []waBinary.Node{{
+ Tag: buttonType,
+ Attrs: getButtonAttributes(message),
+ }},
+ })
+ }
}
return content
}
diff --git a/send_test.go b/send_test.go
new file mode 100644
index 000000000..a03bc5af9
--- /dev/null
+++ b/send_test.go
@@ -0,0 +1,130 @@
+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/sendfb.go b/sendfb.go
index 3c21c6015..7b5da2c22 100644
--- a/sendfb.go
+++ b/sendfb.go
@@ -23,15 +23,15 @@ import (
"go.mau.fi/util/random"
"google.golang.org/protobuf/proto"
- waBinary "go.mau.fi/whatsmeow/binary"
- armadillo "go.mau.fi/whatsmeow/proto"
- "go.mau.fi/whatsmeow/proto/waArmadilloApplication"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waConsumerApplication"
- "go.mau.fi/whatsmeow/proto/waMsgApplication"
- "go.mau.fi/whatsmeow/proto/waMsgTransport"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ armadillo "github.com/polymorfa/hypermeow/proto"
+ "github.com/polymorfa/hypermeow/proto/waArmadilloApplication"
+ "github.com/polymorfa/hypermeow/proto/waCommon"
+ "github.com/polymorfa/hypermeow/proto/waConsumerApplication"
+ "github.com/polymorfa/hypermeow/proto/waMsgApplication"
+ "github.com/polymorfa/hypermeow/proto/waMsgTransport"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
const FBMessageVersion = 3
@@ -198,7 +198,7 @@ func (cli *Client) SendFBMessage(
err = fmt.Errorf("%w %d", ErrServerReturnedError, errorCode)
}
expectedPHash := ag.OptionalString("phash")
- if len(expectedPHash) > 0 && phash != expectedPHash {
+ if setParticipantHashMismatch(&resp, phash, expectedPHash) {
cli.Log.Warnf("Server returned different participant list hash when sending to %s. Some devices may not have received the message.", to)
// TODO also invalidate device list caches
cli.groupCacheLock.Lock()
diff --git a/socket/constants.go b/socket/constants.go
index bb3af280e..19df5d828 100644
--- a/socket/constants.go
+++ b/socket/constants.go
@@ -13,7 +13,7 @@ package socket
import (
"errors"
- "go.mau.fi/whatsmeow/binary/token"
+ "github.com/polymorfa/hypermeow/binary/token"
)
const (
diff --git a/socket/framesocket.go b/socket/framesocket.go
index d222e121f..bcf107b2f 100644
--- a/socket/framesocket.go
+++ b/socket/framesocket.go
@@ -16,7 +16,7 @@ import (
"github.com/coder/websocket"
- waLog "go.mau.fi/whatsmeow/util/log"
+ waLog "github.com/polymorfa/hypermeow/util/log"
)
type FrameSocket struct {
diff --git a/socket/noisehandshake.go b/socket/noisehandshake.go
index c061f22bb..d0002f015 100644
--- a/socket/noisehandshake.go
+++ b/socket/noisehandshake.go
@@ -17,7 +17,7 @@ import (
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
- "go.mau.fi/whatsmeow/util/gcmutil"
+ "github.com/polymorfa/hypermeow/util/gcmutil"
)
type NoiseHandshake struct {
diff --git a/store/clientpayload.go b/store/clientpayload.go
index 891f8eda4..b3d50427c 100644
--- a/store/clientpayload.go
+++ b/store/clientpayload.go
@@ -17,9 +17,9 @@ import (
"github.com/polymorfa/libsignal-protocol-go/ecc"
- "go.mau.fi/whatsmeow/proto/waCompanionReg"
- "go.mau.fi/whatsmeow/proto/waWa6"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/proto/waCompanionReg"
+ "github.com/polymorfa/hypermeow/proto/waWa6"
+ "github.com/polymorfa/hypermeow/types"
)
// WAVersionContainer is a container for a WhatsApp web version number.
@@ -151,6 +151,7 @@ var DeviceProps = &waCompanionReg.DeviceProps{
InitialSyncMaxMessagesPerChat: nil,
SupportManusHistory: proto.Bool(true),
SupportHatchHistory: proto.Bool(true),
+ SupportInlineContacts: proto.Bool(true),
},
PlatformType: waCompanionReg.DeviceProps_UNKNOWN.Enum(),
RequireFullSync: proto.Bool(false),
diff --git a/store/clientpayload_test.go b/store/clientpayload_test.go
new file mode 100644
index 000000000..3ad0c0c1e
--- /dev/null
+++ b/store/clientpayload_test.go
@@ -0,0 +1,9 @@
+package store
+
+import "testing"
+
+func TestDevicePropsAdvertiseInlineContacts(t *testing.T) {
+ if !DeviceProps.GetHistorySyncConfig().GetSupportInlineContacts() {
+ t.Fatal("device properties do not advertise inline contact history")
+ }
+}
diff --git a/store/contact_test.go b/store/contact_test.go
new file mode 100644
index 000000000..e631ad373
--- /dev/null
+++ b/store/contact_test.go
@@ -0,0 +1,20 @@
+package store
+
+import (
+ "testing"
+
+ "github.com/polymorfa/hypermeow/types"
+)
+
+func TestContactEntryMassInsertIncludesUsername(t *testing.T) {
+ entry := ContactEntry{
+ JID: types.NewJID("100000011111111", types.HiddenUserServer),
+ FirstName: "Example",
+ FullName: "Example User",
+ Username: "example",
+ }
+ values := entry.GetMassInsertValues()
+ if values[3] != "example" {
+ t.Fatalf("username value = %#v", values[3])
+ }
+}
diff --git a/store/noop.go b/store/noop.go
index 0afbfa785..6ad63fdb8 100644
--- a/store/noop.go
+++ b/store/noop.go
@@ -11,8 +11,8 @@ import (
"errors"
"time"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/util/keys"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/util/keys"
)
type NoopStore struct {
@@ -61,6 +61,14 @@ func (n *NoopStore) IsTrustedIdentity(ctx context.Context, address string, key [
return false, n.Error
}
+func (n *NoopStore) GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, uint64, error) {
+ return nil, 0, n.Error
+}
+
+func (n *NoopStore) EnsureIdentity(ctx context.Context, address string, key [32]byte, deleteGeneration uint64) (bool, error) {
+ return false, n.Error
+}
+
func (n *NoopStore) GetSession(ctx context.Context, address string) ([]byte, error) {
return nil, n.Error
}
@@ -281,6 +289,10 @@ func (n *NoopStore) GetManyLIDsForPNs(ctx context.Context, pns []types.JID) (map
return nil, n.Error
}
+func (n *NoopStore) GetManyPNsForLIDs(ctx context.Context, lids []types.JID) (map[types.JID]types.JID, error) {
+ return nil, n.Error
+}
+
func (n *NoopStore) GetPNForLID(ctx context.Context, lid types.JID) (types.JID, error) {
return types.JID{}, n.Error
}
diff --git a/store/sessioncache_test.go b/store/sessioncache_test.go
index cab589a38..0bced960a 100644
--- a/store/sessioncache_test.go
+++ b/store/sessioncache_test.go
@@ -5,7 +5,8 @@ import (
"testing"
"github.com/polymorfa/libsignal-protocol-go/protocol"
- "go.mau.fi/whatsmeow/types"
+
+ "github.com/polymorfa/hypermeow/types"
)
type countingSessionStore struct {
diff --git a/store/sqlstore/container.go b/store/sqlstore/container.go
index 516eda0ff..3ef6bf294 100644
--- a/store/sqlstore/container.go
+++ b/store/sqlstore/container.go
@@ -17,12 +17,12 @@ import (
"go.mau.fi/util/dbutil"
"go.mau.fi/util/random"
- "go.mau.fi/whatsmeow/proto/waAdv"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/store/sqlstore/upgrades"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/util/keys"
- waLog "go.mau.fi/whatsmeow/util/log"
+ "github.com/polymorfa/hypermeow/proto/waAdv"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/store/sqlstore/upgrades"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/util/keys"
+ waLog "github.com/polymorfa/hypermeow/util/log"
)
// Container is a wrapper for a SQL database that can contain multiple whatsmeow sessions.
diff --git a/store/sqlstore/identity_reader_test.go b/store/sqlstore/identity_reader_test.go
new file mode 100644
index 000000000..87e73bf2d
--- /dev/null
+++ b/store/sqlstore/identity_reader_test.go
@@ -0,0 +1,111 @@
+package sqlstore
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "errors"
+ "io"
+ "testing"
+
+ "github.com/polymorfa/hypermeow/types"
+)
+
+type identityReaderState struct {
+ queries int
+}
+
+type identityReaderConnector struct {
+ state *identityReaderState
+}
+
+func (c *identityReaderConnector) Connect(context.Context) (driver.Conn, error) {
+ return &identityReaderConn{state: c.state}, nil
+}
+
+func (*identityReaderConnector) Driver() driver.Driver {
+ return identityReaderDriver{}
+}
+
+type identityReaderDriver struct{}
+
+func (identityReaderDriver) Open(string) (driver.Conn, error) {
+ return nil, errors.New("use connector")
+}
+
+type identityReaderConn struct {
+ state *identityReaderState
+}
+
+func (*identityReaderConn) Prepare(string) (driver.Stmt, error) {
+ return nil, errors.New("unexpected prepare")
+}
+
+func (*identityReaderConn) Close() error {
+ return nil
+}
+
+func (*identityReaderConn) Begin() (driver.Tx, error) {
+ return nil, errors.New("unexpected transaction")
+}
+
+func (c *identityReaderConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
+ c.state.queries++
+ return &identityReaderRows{}, nil
+}
+
+type identityReaderRows struct {
+ index int
+}
+
+func (*identityReaderRows) Columns() []string {
+ return []string{"their_id", "identity"}
+}
+
+func (*identityReaderRows) Close() error {
+ return nil
+}
+
+func (r *identityReaderRows) Next(values []driver.Value) error {
+ rows := []struct {
+ address string
+ key byte
+ }{
+ {address: "100000000000001:1", key: 0x11},
+ {address: "100000000000001:2", key: 0x22},
+ }
+ if r.index >= len(rows) {
+ return io.EOF
+ }
+ row := rows[r.index]
+ r.index++
+ values[0] = row.address
+ values[1] = bytes.Repeat([]byte{row.key}, 32)
+ return nil
+}
+
+func TestGetManyIdentitiesUsesOneQueryAndCachesResults(t *testing.T) {
+ state := &identityReaderState{}
+ db := sql.OpenDB(&identityReaderConnector{state: state})
+ t.Cleanup(func() { _ = db.Close() })
+ store := NewSQLStore(
+ NewWithDB(db, "sqlite3", nil),
+ types.NewJID("100000000000000", types.HiddenUserServer),
+ )
+ addresses := []string{"100000000000001:1", "100000000000001:2"}
+
+ got, _, err := store.GetManyIdentities(context.Background(), addresses)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 || got[addresses[0]][0] != 0x11 || got[addresses[1]][0] != 0x22 {
+ t.Fatalf("unexpected identities: %#v", got)
+ }
+ if _, _, err = store.GetManyIdentities(context.Background(), addresses); err != nil {
+ t.Fatal(err)
+ }
+ if state.queries != 1 {
+ t.Fatalf("database queries = %d, want 1", state.queries)
+ }
+}
diff --git a/store/sqlstore/lidmap.go b/store/sqlstore/lidmap.go
index 793f7114e..3613aa1e3 100644
--- a/store/sqlstore/lidmap.go
+++ b/store/sqlstore/lidmap.go
@@ -20,8 +20,8 @@ import (
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exslices"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
)
type CachedLIDMap struct {
@@ -34,6 +34,7 @@ type CachedLIDMap struct {
}
var _ store.LIDStore = (*CachedLIDMap)(nil)
+var _ store.LIDBatchReverseStore = (*CachedLIDMap)(nil)
const maxLIDCacheEntries = 65536
@@ -243,6 +244,104 @@ func (s *CachedLIDMap) GetManyLIDsForPNs(ctx context.Context, pns []types.JID) (
return result, err
}
+func (s *CachedLIDMap) GetManyPNsForLIDs(ctx context.Context, lids []types.JID) (map[types.JID]types.JID, error) {
+ if len(lids) == 0 {
+ return nil, nil
+ }
+
+ result := make(map[types.JID]types.JID, len(lids))
+
+ s.lidCacheLock.RLock()
+ missingLIDs := make([]string, 0, len(lids))
+ missingLIDDevices := make(map[string][]types.JID)
+ for _, lid := range lids {
+ if lid.Server != types.HiddenUserServer {
+ continue
+ }
+ if pnUser, ok := s.lidToPNCache[lid.User]; ok {
+ if pnUser != "" {
+ result[lid] = types.JID{User: pnUser, Device: lid.Device, Server: types.DefaultUserServer}
+ }
+ continue
+ }
+ if !s.cacheFilled {
+ if _, exists := missingLIDDevices[lid.User]; !exists {
+ missingLIDs = append(missingLIDs, lid.User)
+ }
+ missingLIDDevices[lid.User] = append(missingLIDDevices[lid.User], lid)
+ }
+ }
+ s.lidCacheLock.RUnlock()
+
+ if len(missingLIDs) == 0 {
+ return result, nil
+ }
+
+ s.lidCacheLock.Lock()
+ defer s.lidCacheLock.Unlock()
+ queryLIDs := missingLIDs[:0]
+ for _, lid := range missingLIDs {
+ if pn, ok := s.lidToPNCache[lid]; ok {
+ if pn != "" {
+ for _, dev := range missingLIDDevices[lid] {
+ result[dev] = types.JID{User: pn, Device: dev.Device, Server: types.DefaultUserServer}
+ }
+ }
+ } else if !s.cacheFilled {
+ queryLIDs = append(queryLIDs, lid)
+ }
+ }
+ missingLIDs = queryLIDs
+ if len(missingLIDs) == 0 {
+ return result, nil
+ }
+
+ found := make(map[string]struct{}, len(missingLIDs))
+ scanResults := func(res dbutil.RowIter[store.LIDMapping]) error {
+ _, err := s.scanManyLids(res, func(lid, pn string) {
+ found[lid] = struct{}{}
+ for _, dev := range missingLIDDevices[lid] {
+ pnDev := dev
+ pnDev.Server = types.DefaultUserServer
+ pnDev.User = pn
+ result[dev] = pnDev
+ }
+ })
+ return err
+ }
+ if wrapped, ok := wrapPostgresArray(s.db, missingLIDs); ok {
+ if err := scanResults(convertLIDRow.NewRowIter(s.db.Query(
+ ctx,
+ `SELECT lid, pn FROM whatsmeow_lid_map WHERE lid = ANY($1)`,
+ wrapped,
+ ))); err != nil {
+ return result, err
+ }
+ } else {
+ for lids := range slices.Chunk(missingLIDs, lidQueryBatchSize) {
+ placeholders := make([]string, len(lids))
+ for i := range lids {
+ placeholders[i] = fmt.Sprintf("$%d", i+1)
+ }
+ if err := scanResults(convertLIDRow.NewRowIter(s.db.Query(
+ ctx,
+ fmt.Sprintf(`SELECT lid, pn FROM whatsmeow_lid_map WHERE lid IN (%s)`, strings.Join(placeholders, ",")),
+ exslices.CastToAny(lids)...,
+ ))); err != nil {
+ return result, err
+ }
+ }
+ }
+ for _, lid := range missingLIDs {
+ if _, ok := found[lid]; !ok {
+ s.cacheMissLocked(s.lidToPNCache, s.pnToLIDCache, lid)
+ }
+ }
+ return result, nil
+}
+
+const lidQueryBatchSize = 300
+
func (s *CachedLIDMap) PutLIDMapping(ctx context.Context, lid, pn types.JID) error {
if lid.Server != types.HiddenUserServer || pn.Server != types.DefaultUserServer {
return fmt.Errorf("invalid PutLIDMapping call %s/%s", lid, pn)
diff --git a/store/sqlstore/lidmap_test.go b/store/sqlstore/lidmap_test.go
index 3b98d3a4b..1303b2a3b 100644
--- a/store/sqlstore/lidmap_test.go
+++ b/store/sqlstore/lidmap_test.go
@@ -1,10 +1,50 @@
package sqlstore
import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "errors"
"fmt"
+ "io"
"testing"
+
+ "github.com/polymorfa/hypermeow/types"
)
+type emptyLIDMapDB struct {
+ queries int
+ maxArgs int
+}
+
+type emptyLIDMapConnector struct{ state *emptyLIDMapDB }
+
+func (connector *emptyLIDMapConnector) Connect(context.Context) (driver.Conn, error) {
+ return &emptyLIDMapConn{state: connector.state}, nil
+}
+
+func (*emptyLIDMapConnector) Driver() driver.Driver { return pnMigrationTestDriver{} }
+
+type emptyLIDMapConn struct{ state *emptyLIDMapDB }
+
+func (*emptyLIDMapConn) Prepare(string) (driver.Stmt, error) {
+ return nil, errors.New("unexpected prepare")
+}
+
+func (*emptyLIDMapConn) Close() error { return nil }
+func (*emptyLIDMapConn) Begin() (driver.Tx, error) { return nil, errors.New("unexpected transaction") }
+func (conn *emptyLIDMapConn) QueryContext(_ context.Context, _ string, args []driver.NamedValue) (driver.Rows, error) {
+ conn.state.queries++
+ conn.state.maxArgs = max(conn.state.maxArgs, len(args))
+ return emptyLIDMapRows{}, nil
+}
+
+type emptyLIDMapRows struct{}
+
+func (emptyLIDMapRows) Columns() []string { return []string{"lid", "pn"} }
+func (emptyLIDMapRows) Close() error { return nil }
+func (emptyLIDMapRows) Next([]driver.Value) error { return io.EOF }
+
func TestLIDCacheIsBounded(t *testing.T) {
cache := NewCachedLIDMap(nil)
for i := 0; i <= maxLIDCacheEntries; i++ {
@@ -26,3 +66,67 @@ func TestLIDCacheIsBounded(t *testing.T) {
}
}
}
+
+func TestGetManyPNsForLIDsUsesReverseCache(t *testing.T) {
+ cache := NewCachedLIDMap(nil)
+ cache.cacheFilled = true
+ cache.cacheMappingLocked("100000000000001", "15550000001")
+ cache.cacheMappingLocked("100000000000002", "15550000002")
+
+ lids := []types.JID{
+ types.NewJID("100000000000001", types.HiddenUserServer),
+ types.NewJID("100000000000002", types.HiddenUserServer),
+ }
+ got, err := cache.GetManyPNsForLIDs(context.Background(), lids)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, lid := range lids {
+ want := fmt.Sprintf("1555000000%d@s.whatsapp.net", i+1)
+ if got[lid].String() != want {
+ t.Fatalf("phone for %s = %s, want %s", lid, got[lid], want)
+ }
+ }
+}
+
+func TestGetManyPNsForLIDsCachesMissingMappings(t *testing.T) {
+ state := &emptyLIDMapDB{}
+ db := sql.OpenDB(&emptyLIDMapConnector{state: state})
+ t.Cleanup(func() { _ = db.Close() })
+ cache := NewWithDB(db, "sqlite3", nil).LIDMap
+ lid := types.NewJID("100000000000001", types.HiddenUserServer)
+
+ for range 2 {
+ got, err := cache.GetManyPNsForLIDs(context.Background(), []types.JID{lid})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("missing mapping returned %#v", got)
+ }
+ }
+ if state.queries != 1 {
+ t.Fatalf("missing mapping queried %d times, want 1", state.queries)
+ }
+}
+
+func TestGetManyPNsForLIDsChunksFallbackQueries(t *testing.T) {
+ state := &emptyLIDMapDB{}
+ db := sql.OpenDB(&emptyLIDMapConnector{state: state})
+ t.Cleanup(func() { _ = db.Close() })
+ cache := NewWithDB(db, "sqlite3", nil).LIDMap
+ lids := make([]types.JID, lidQueryBatchSize*2+1)
+ for i := range lids {
+ lids[i] = types.NewJID(fmt.Sprintf("%d", 100000000000000+i), types.HiddenUserServer)
+ }
+
+ if _, err := cache.GetManyPNsForLIDs(context.Background(), lids); err != nil {
+ t.Fatal(err)
+ }
+ if state.queries != 3 {
+ t.Fatalf("fallback issued %d queries, want 3", state.queries)
+ }
+ if state.maxArgs > lidQueryBatchSize {
+ t.Fatalf("fallback query used %d arguments, limit %d", state.maxArgs, lidQueryBatchSize)
+ }
+}
diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go
index bb41f0dd2..014af5d71 100644
--- a/store/sqlstore/store.go
+++ b/store/sqlstore/store.go
@@ -22,9 +22,9 @@ import (
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exslices"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/util/keys"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/util/keys"
)
// ErrInvalidLength is returned by some database getters if the database returned a byte array with an unexpected length.
@@ -75,8 +75,10 @@ type SQLStore struct {
contactCacheLock sync.Mutex
identityCache map[string]identityCacheEntry
identityCacheLock sync.RWMutex
+ identityDeleteGen uint64
migratedPNSessionsCache map[string]struct{}
+ emptyPNMigrationCache map[string]time.Time
migratingPNSessions map[string]struct{}
migratedPNSessionsCacheLock sync.Mutex
}
@@ -90,6 +92,7 @@ const (
maxContactCacheEntries = 256
maxIdentityCacheEntries = 2048
maxMigratedPNEntries = 1024
+ emptyPNMigrationTTL = time.Minute
)
func setBoundedCacheEntry[K comparable, V any](cache map[K]V, key K, value V, limit int) {
@@ -134,9 +137,15 @@ const (
INSERT INTO whatsmeow_identity_keys (our_jid, their_id, identity) VALUES ($1, $2, $3)
ON CONFLICT (our_jid, their_id) DO UPDATE SET identity=excluded.identity
`
- deleteAllIdentitiesQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2`
- deleteIdentityQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2`
- getIdentityQuery = `SELECT identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2`
+ ensureIdentityQuery = `
+ INSERT INTO whatsmeow_identity_keys (our_jid, their_id, identity) VALUES ($1, $2, $3)
+ ON CONFLICT (our_jid, their_id) DO NOTHING
+ `
+ deleteAllIdentitiesQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3`
+ deleteIdentityQuery = `DELETE FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2`
+ getIdentityQuery = `SELECT identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id=$2`
+ getManyIdentityQueryPostgres = `SELECT their_id, identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id = ANY($2)`
+ getManyIdentityQueryGeneric = `SELECT their_id, identity FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id IN (%s)`
)
func (s *SQLStore) PutIdentity(ctx context.Context, address string, key [32]byte) error {
@@ -161,11 +170,13 @@ func (s *SQLStore) PutIdentity(ctx context.Context, address string, key [32]byte
func (s *SQLStore) DeleteAllIdentities(ctx context.Context, phone string) error {
s.identityCacheLock.Lock()
defer s.identityCacheLock.Unlock()
- _, err := s.db.Exec(ctx, deleteAllIdentitiesQuery, s.JID, phone+":%")
+ lower, upper := signalAddressRange(phone)
+ _, err := s.db.Exec(ctx, deleteAllIdentitiesQuery, s.JID, lower, upper)
if err == nil {
+ s.identityDeleteGen++
for address := range s.identityCache {
if strings.HasPrefix(address, phone+":") {
- delete(s.identityCache, address)
+ s.setCachedIdentityLocked(address, identityCacheEntry{})
}
}
}
@@ -177,7 +188,8 @@ func (s *SQLStore) DeleteIdentity(ctx context.Context, address string) error {
defer s.identityCacheLock.Unlock()
_, err := s.db.Exec(ctx, deleteIdentityQuery, s.JID, address)
if err == nil {
- delete(s.identityCache, address)
+ s.identityDeleteGen++
+ s.setCachedIdentityLocked(address, identityCacheEntry{})
}
return err
}
@@ -210,6 +222,143 @@ func (s *SQLStore) IsTrustedIdentity(ctx context.Context, address string, key [3
return existingKey == key, nil
}
+func (s *SQLStore) EnsureIdentity(ctx context.Context, address string, key [32]byte, deleteGeneration uint64) (bool, error) {
+ s.identityCacheLock.Lock()
+ defer s.identityCacheLock.Unlock()
+ if deleteGeneration != s.identityDeleteGen {
+ return false, nil
+ }
+ if cached, ok := s.identityCache[address]; ok && cached.Present {
+ return cached.Key == key, nil
+ }
+ if _, err := s.db.Exec(ctx, ensureIdentityQuery, s.JID, address, key[:]); err != nil {
+ return false, err
+ }
+ var existingIdentity []byte
+ if err := s.db.QueryRow(ctx, getIdentityQuery, s.JID, address).Scan(&existingIdentity); err != nil {
+ return false, err
+ }
+ if len(existingIdentity) != 32 {
+ return false, ErrInvalidLength
+ }
+ existingKey := *(*[32]byte)(existingIdentity)
+ s.setCachedIdentityLocked(address, identityCacheEntry{Key: existingKey, Present: true})
+ return existingKey == key, nil
+}
+
+type addressIdentityTuple struct {
+ Address string
+ Identity []byte
+}
+
+var identityScanner = dbutil.ConvertRowFn[addressIdentityTuple](func(row dbutil.Scannable) (out addressIdentityTuple, err error) {
+ err = row.Scan(&out.Address, &out.Identity)
+ return
+})
+
+func (s *SQLStore) queryManyIdentities(ctx context.Context, addresses []string) (dbutil.Rows, error) {
+ if wrapped, ok := wrapPostgresArray(s.db, addresses); ok {
+ return s.db.Query(ctx, getManyIdentityQueryPostgres, s.JID, wrapped)
+ }
+ args := make([]any, len(addresses)+1)
+ placeholders := make([]string, len(addresses))
+ args[0] = s.JID
+ for i, address := range addresses {
+ args[i+1] = address
+ placeholders[i] = fmt.Sprintf("$%d", i+2)
+ }
+ return s.db.Query(ctx, fmt.Sprintf(getManyIdentityQueryGeneric, strings.Join(placeholders, ",")), args...)
+}
+
+func (s *SQLStore) GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, uint64, error) {
+ if len(addresses) == 0 {
+ s.identityCacheLock.RLock()
+ generation := s.identityDeleteGen
+ s.identityCacheLock.RUnlock()
+ return nil, generation, nil
+ }
+ result := make(map[string][32]byte, len(addresses))
+ missing := make([]string, 0, len(addresses))
+ seen := make(map[string]struct{}, len(addresses))
+ s.identityCacheLock.RLock()
+ generation := s.identityDeleteGen
+ for _, address := range addresses {
+ if _, duplicate := seen[address]; duplicate {
+ continue
+ }
+ seen[address] = struct{}{}
+ if cached, ok := s.identityCache[address]; ok {
+ if cached.Present {
+ result[address] = cached.Key
+ }
+ } else {
+ missing = append(missing, address)
+ }
+ }
+ s.identityCacheLock.RUnlock()
+ if len(missing) == 0 {
+ return result, generation, nil
+ }
+
+ s.identityCacheLock.Lock()
+ defer s.identityCacheLock.Unlock()
+ generation = s.identityDeleteGen
+ stillMissing := missing[:0]
+ for _, address := range missing {
+ if cached, ok := s.identityCache[address]; ok {
+ if cached.Present {
+ result[address] = cached.Key
+ }
+ } else {
+ stillMissing = append(stillMissing, address)
+ }
+ }
+ missing = stillMissing
+ if len(missing) == 0 {
+ return result, generation, nil
+ }
+
+ rows, err := s.queryManyIdentities(ctx, missing)
+ fetched := make(map[string]identityCacheEntry, len(missing))
+ for _, address := range missing {
+ fetched[address] = identityCacheEntry{}
+ }
+ err = identityScanner.NewRowIter(rows, err).Iter(func(tuple addressIdentityTuple) (bool, error) {
+ if len(tuple.Identity) != 32 {
+ return false, ErrInvalidLength
+ }
+ key := *(*[32]byte)(tuple.Identity)
+ fetched[tuple.Address] = identityCacheEntry{Key: key, Present: true}
+ result[tuple.Address] = key
+ return true, nil
+ })
+ if err != nil {
+ return nil, 0, err
+ }
+ s.cacheFetchedIdentitiesLocked(result, fetched)
+ return result, generation, nil
+}
+
+func (s *SQLStore) cacheFetchedIdentities(result map[string][32]byte, fetched map[string]identityCacheEntry) {
+ s.identityCacheLock.Lock()
+ defer s.identityCacheLock.Unlock()
+ s.cacheFetchedIdentitiesLocked(result, fetched)
+}
+
+func (s *SQLStore) cacheFetchedIdentitiesLocked(result map[string][32]byte, fetched map[string]identityCacheEntry) {
+ for address, entry := range fetched {
+ if current, ok := s.identityCache[address]; ok {
+ if current.Present {
+ result[address] = current.Key
+ } else {
+ delete(result, address)
+ }
+ continue
+ }
+ s.setCachedIdentityLocked(address, entry)
+ }
+}
+
const (
getSessionQuery = `SELECT session FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id=$2`
hasSessionQuery = `SELECT true FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id=$2`
@@ -227,7 +376,7 @@ const (
FROM UNNEST($2::text[], $3::bytea[]) AS batch(their_id, session)
ON CONFLICT (our_jid, their_id) DO UPDATE SET session=excluded.session
`
- deleteAllSessionsQuery = `DELETE FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id LIKE $2`
+ deleteAllSessionsQuery = `DELETE FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3`
deleteSessionQuery = `DELETE FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id=$2`
migratePNToLIDSessionsQuery = `
@@ -253,6 +402,16 @@ const (
WHERE our_jid=$1 AND sender_id LIKE $2 || ':%'
ON CONFLICT (our_jid, chat_id, sender_id) DO UPDATE SET sender_key=excluded.sender_key
`
+ hasPNRowsToMigrateQuery = `
+ SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id LIKE $2)
+ OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id LIKE $2)
+ OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id LIKE $2)
+ `
+ hasPNRowsToMigrateSQLiteQuery = `
+ SELECT EXISTS(SELECT 1 FROM whatsmeow_sessions WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3)
+ OR EXISTS(SELECT 1 FROM whatsmeow_identity_keys WHERE our_jid=$1 AND their_id >= $2 AND their_id < $3)
+ OR EXISTS(SELECT 1 FROM whatsmeow_sender_keys WHERE our_jid=$1 AND sender_id >= $2 AND sender_id < $3)
+ `
)
func (s *SQLStore) GetSession(ctx context.Context, address string) (session []byte, err error) {
@@ -429,10 +588,15 @@ func (s *SQLStore) DeleteAllSessions(ctx context.Context, phone string) error {
}
func (s *SQLStore) deleteAllSessions(ctx context.Context, phone string) error {
- _, err := s.db.Exec(ctx, deleteAllSessionsQuery, s.JID, phone+":%")
+ lower, upper := signalAddressRange(phone)
+ _, err := s.db.Exec(ctx, deleteAllSessionsQuery, s.JID, lower, upper)
return err
}
+func signalAddressRange(user string) (string, string) {
+ return user + ":", user + ";"
+}
+
func (s *SQLStore) deleteAllSenderKeys(ctx context.Context, phone string) error {
_, err := s.db.Exec(ctx, deleteAllSenderKeysQuery, s.JID, phone+":%")
return err
@@ -453,19 +617,37 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error
s.migratedPNSessionsCacheLock.Lock()
_, migrated := s.migratedPNSessionsCache[pnSignal]
_, migrating := s.migratingPNSessions[pnSignal]
- if !migrated && !migrating {
+ emptyUntil, empty := s.emptyPNMigrationCache[pnSignal]
+ if empty && !time.Now().Before(emptyUntil) {
+ delete(s.emptyPNMigrationCache, pnSignal)
+ empty = false
+ }
+ if !migrated && !migrating && !empty {
if s.migratingPNSessions == nil {
s.migratingPNSessions = make(map[string]struct{})
}
s.migratingPNSessions[pnSignal] = struct{}{}
}
s.migratedPNSessionsCacheLock.Unlock()
- if migrated || migrating {
+ if migrated || migrating || empty {
+ return nil
+ }
+ var hasPNRows bool
+ var err error
+ if s.db.Dialect == dbutil.SQLite {
+ err = s.db.QueryRow(ctx, hasPNRowsToMigrateSQLiteQuery, s.JID, pnSignal+":", pnSignal+";").Scan(&hasPNRows)
+ } else {
+ err = s.db.QueryRow(ctx, hasPNRowsToMigrateQuery, s.JID, pnSignal+":%").Scan(&hasPNRows)
+ }
+ if err != nil {
+ s.log.Warnf("Failed to check for PN rows to migrate from %s: %v", pnSignal, err)
+ } else if !hasPNRows {
+ s.finishEmptyPNMigration(pnSignal)
return nil
}
var sessionsUpdated, identityKeysUpdated, senderKeysUpdated int64
lidSignal := lid.SignalAddressUser()
- err := s.db.DoTxn(ctx, nil, func(ctx context.Context) error {
+ err = s.db.DoTxn(ctx, nil, func(ctx context.Context) error {
res, err := s.db.Exec(ctx, migratePNToLIDSessionsQuery, s.JID, pnSignal, lidSignal)
if err != nil {
return fmt.Errorf("failed to migrate sessions: %w", err)
@@ -506,15 +688,7 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error
}
return nil
})
- s.migratedPNSessionsCacheLock.Lock()
- delete(s.migratingPNSessions, pnSignal)
- if err == nil {
- if s.migratedPNSessionsCache == nil {
- s.migratedPNSessionsCache = make(map[string]struct{})
- }
- setBoundedCacheEntry(s.migratedPNSessionsCache, pnSignal, struct{}{}, maxMigratedPNEntries)
- }
- s.migratedPNSessionsCacheLock.Unlock()
+ s.finishPNMigration(pnSignal, err == nil)
if err != nil {
return err
}
@@ -529,6 +703,29 @@ func (s *SQLStore) MigratePNToLID(ctx context.Context, pn, lid types.JID) error
return nil
}
+func (s *SQLStore) finishPNMigration(pnSignal string, markMigrated bool) {
+ s.migratedPNSessionsCacheLock.Lock()
+ defer s.migratedPNSessionsCacheLock.Unlock()
+ delete(s.migratingPNSessions, pnSignal)
+ delete(s.emptyPNMigrationCache, pnSignal)
+ if markMigrated {
+ if s.migratedPNSessionsCache == nil {
+ s.migratedPNSessionsCache = make(map[string]struct{})
+ }
+ setBoundedCacheEntry(s.migratedPNSessionsCache, pnSignal, struct{}{}, maxMigratedPNEntries)
+ }
+}
+
+func (s *SQLStore) finishEmptyPNMigration(pnSignal string) {
+ s.migratedPNSessionsCacheLock.Lock()
+ defer s.migratedPNSessionsCacheLock.Unlock()
+ delete(s.migratingPNSessions, pnSignal)
+ if s.emptyPNMigrationCache == nil {
+ s.emptyPNMigrationCache = make(map[string]time.Time)
+ }
+ setBoundedCacheEntry(s.emptyPNMigrationCache, pnSignal, time.Now().Add(emptyPNMigrationTTL), maxMigratedPNEntries)
+}
+
const (
getLastPreKeyIDQuery = `SELECT MAX(key_id) FROM whatsmeow_pre_keys WHERE jid=$1`
insertPreKeyQuery = `INSERT INTO whatsmeow_pre_keys (jid, key_id, key, uploaded) VALUES ($1, $2, $3, $4)`
@@ -810,6 +1007,18 @@ const (
INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name) VALUES ($1, $2, $3, $4)
ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name
`
+ putContactNamesQuery = `
+ INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name, username) VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name, username=excluded.username
+ `
+ putContactNamesWithoutUsernameQuery = `
+ INSERT INTO whatsmeow_contacts (our_jid, their_jid, first_name, full_name) VALUES ($1, $2, $3, $4)
+ ON CONFLICT (our_jid, their_jid) DO UPDATE SET first_name=excluded.first_name, full_name=excluded.full_name
+ `
+ putContactUsernameQuery = `
+ INSERT INTO whatsmeow_contacts (our_jid, their_jid, username) VALUES ($1, $2, $3)
+ ON CONFLICT (our_jid, their_jid) DO UPDATE SET username=excluded.username
+ `
putRedactedPhoneQuery = `
INSERT INTO whatsmeow_contacts (our_jid, their_jid, redacted_phone)
VALUES ($1, $2, $3)
@@ -824,15 +1033,42 @@ const (
ON CONFLICT (our_jid, their_jid) DO UPDATE SET business_name=excluded.business_name
`
getContactQuery = `
- SELECT first_name, full_name, push_name, business_name, redacted_phone FROM whatsmeow_contacts WHERE our_jid=$1 AND their_jid=$2
+ SELECT first_name, full_name, push_name, business_name, redacted_phone, username FROM whatsmeow_contacts WHERE our_jid=$1 AND their_jid=$2
`
getAllContactsQuery = `
- SELECT their_jid, first_name, full_name, push_name, business_name, redacted_phone FROM whatsmeow_contacts WHERE our_jid=$1
+ SELECT their_jid, first_name, full_name, push_name, business_name, redacted_phone, username FROM whatsmeow_contacts WHERE our_jid=$1
`
)
var putContactNamesMassInsertBuilder = dbutil.NewMassInsertBuilder[store.ContactEntry, [1]any](
- putContactNameQuery, "($1, $%d, $%d, $%d)",
+ putContactNamesQuery, "($1, $%d, $%d, $%d, $%d)",
+)
+
+type contactNameEntryWithoutUsername store.ContactEntry
+
+func (entry contactNameEntryWithoutUsername) GetMassInsertValues() [3]any {
+ return [...]any{entry.JID.String(), entry.FirstName, entry.FullName}
+}
+
+var putContactNamesWithoutUsernameMassInsertBuilder = dbutil.NewMassInsertBuilder[contactNameEntryWithoutUsername, [1]any](
+ putContactNamesWithoutUsernameQuery, "($1, $%d, $%d, $%d)",
+)
+
+func splitContactNameEntries(contacts []store.ContactEntry) ([]store.ContactEntry, []contactNameEntryWithoutUsername) {
+ withUsername := make([]store.ContactEntry, 0, len(contacts))
+ withoutUsername := make([]contactNameEntryWithoutUsername, 0, len(contacts))
+ for _, contact := range contacts {
+ if contact.UsernameSet || contact.Username != "" {
+ withUsername = append(withUsername, contact)
+ } else {
+ withoutUsername = append(withoutUsername, contactNameEntryWithoutUsername(contact))
+ }
+ }
+ return withUsername, withoutUsername
+}
+
+var putContactUsernamesMassInsertBuilder = dbutil.NewMassInsertBuilder[store.ContactUsernameEntry, [1]any](
+ putContactUsernameQuery, "($1, $%d, $%d)",
)
var putRedactedPhonesMassInsertBuilder = dbutil.NewMassInsertBuilder[store.RedactedPhoneEntry, [1]any](
@@ -901,6 +1137,66 @@ func (s *SQLStore) PutContactName(ctx context.Context, user types.JID, firstName
return nil
}
+func (s *SQLStore) PutContactUsername(ctx context.Context, user types.JID, username string) error {
+ s.contactCacheLock.Lock()
+ defer s.contactCacheLock.Unlock()
+
+ cached, err := s.getContact(ctx, user)
+ if err != nil {
+ return err
+ }
+ if cached.Username != username {
+ if _, err = s.db.Exec(ctx, putContactUsernameQuery, s.JID, user, username); err != nil {
+ return err
+ }
+ cached.Username = username
+ cached.Found = true
+ }
+ return nil
+}
+
+func (s *SQLStore) PutManyContactUsernames(ctx context.Context, entries []store.ContactUsernameEntry) error {
+ if len(entries) == 0 {
+ return nil
+ }
+ entries = deduplicateContactUsernamesLastWriteWins(entries)
+ s.contactCacheLock.Lock()
+ defer s.contactCacheLock.Unlock()
+ if err := s.db.DoTxn(ctx, nil, func(ctx context.Context) error {
+ for batch := range slices.Chunk(entries, contactBatchSize) {
+ query, vars := putContactUsernamesMassInsertBuilder.Build([1]any{s.JID}, batch)
+ if _, err := s.db.Exec(ctx, query, vars...); err != nil {
+ return err
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ if cached, ok := s.contactCache[entry.JID]; ok {
+ cached.Username = entry.Username
+ cached.Found = true
+ }
+ }
+ return nil
+}
+
+func deduplicateContactUsernamesLastWriteWins(entries []store.ContactUsernameEntry) []store.ContactUsernameEntry {
+ positions := make(map[types.JID]int, len(entries))
+ out := entries[:0]
+ for _, entry := range entries {
+ if position, ok := positions[entry.JID]; ok {
+ out[position] = entry
+ } else {
+ positions[entry.JID] = len(out)
+ out = append(out, entry)
+ }
+ }
+ clear(entries[len(out):])
+ return out
+}
+
const contactBatchSize = 300
func (s *SQLStore) PutAllContactNames(ctx context.Context, contacts []store.ContactEntry) error {
@@ -914,14 +1210,21 @@ func (s *SQLStore) PutAllContactNames(ctx context.Context, contacts []store.Cont
if origLen != len(contacts) {
s.log.Warnf("%d duplicate contacts found in PutAllContactNames", origLen-len(contacts))
}
+ withUsername, withoutUsername := splitContactNameEntries(contacts)
err := s.db.DoTxn(ctx, nil, func(ctx context.Context) error {
- for slice := range slices.Chunk(contacts, contactBatchSize) {
+ for slice := range slices.Chunk(withUsername, contactBatchSize) {
query, vars := putContactNamesMassInsertBuilder.Build([1]any{s.JID}, slice)
_, err := s.db.Exec(ctx, query, vars...)
if err != nil {
return err
}
}
+ for slice := range slices.Chunk(withoutUsername, contactBatchSize) {
+ query, vars := putContactNamesWithoutUsernameMassInsertBuilder.Build([1]any{s.JID}, slice)
+ if _, err := s.db.Exec(ctx, query, vars...); err != nil {
+ return err
+ }
+ }
return nil
})
if err != nil {
@@ -975,8 +1278,8 @@ func (s *SQLStore) getContact(ctx context.Context, user types.JID) (*types.Conta
return cached, nil
}
- var first, full, push, business, redactedPhone sql.NullString
- err := s.db.QueryRow(ctx, getContactQuery, s.JID, user).Scan(&first, &full, &push, &business, &redactedPhone)
+ var first, full, push, business, redactedPhone, username sql.NullString
+ err := s.db.QueryRow(ctx, getContactQuery, s.JID, user).Scan(&first, &full, &push, &business, &redactedPhone, &username)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
@@ -987,6 +1290,7 @@ func (s *SQLStore) getContact(ctx context.Context, user types.JID) (*types.Conta
PushName: push.String,
BusinessName: business.String,
RedactedPhone: redactedPhone.String,
+ Username: username.String,
}
s.setCachedContactLocked(user, info)
return info, nil
@@ -1009,8 +1313,8 @@ type contactTuple struct {
var convertContactRow = dbutil.ConvertRowFn[*contactTuple](func(rows dbutil.Scannable) (*contactTuple, error) {
var jid types.JID
- var first, full, push, business, redactedPhone sql.NullString
- err := rows.Scan(&jid, &first, &full, &push, &business, &redactedPhone)
+ var first, full, push, business, redactedPhone, username sql.NullString
+ err := rows.Scan(&jid, &first, &full, &push, &business, &redactedPhone, &username)
if err != nil {
return nil, fmt.Errorf("error scanning row: %w", err)
}
@@ -1023,6 +1327,7 @@ var convertContactRow = dbutil.ConvertRowFn[*contactTuple](func(rows dbutil.Scan
PushName: push.String,
BusinessName: business.String,
RedactedPhone: redactedPhone.String,
+ Username: username.String,
},
}, nil
})
diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go
index c0a7d66f3..746cd297c 100644
--- a/store/sqlstore/store_test.go
+++ b/store/sqlstore/store_test.go
@@ -1,15 +1,161 @@
package sqlstore
import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "errors"
"fmt"
+ "io"
+ "strings"
+ "sync"
"testing"
+ "time"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
)
+type pnMigrationTestDB struct {
+ queries int
+ begins int
+ query string
+ args []driver.NamedValue
+}
+
+type pnMigrationTestConnector struct{ state *pnMigrationTestDB }
+
+func (c *pnMigrationTestConnector) Connect(context.Context) (driver.Conn, error) {
+ return &pnMigrationTestConn{state: c.state}, nil
+}
+
+func (*pnMigrationTestConnector) Driver() driver.Driver { return pnMigrationTestDriver{} }
+
+type pnMigrationTestDriver struct{}
+
+func (pnMigrationTestDriver) Open(string) (driver.Conn, error) {
+ return nil, errors.New("use connector")
+}
+
+type pnMigrationTestConn struct{ state *pnMigrationTestDB }
+
+func (*pnMigrationTestConn) Prepare(string) (driver.Stmt, error) {
+ return nil, errors.New("unexpected prepare")
+}
+
+func (*pnMigrationTestConn) Close() error { return nil }
+
+func (c *pnMigrationTestConn) Begin() (driver.Tx, error) {
+ c.state.begins++
+ return nil, errors.New("unexpected transaction")
+}
+
+func (c *pnMigrationTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
+ c.state.queries++
+ c.state.query = query
+ c.state.args = append([]driver.NamedValue(nil), args...)
+ return &pnMigrationTestRows{}, nil
+}
+
+type pnMigrationTestRows struct{ read bool }
+
+func (*pnMigrationTestRows) Columns() []string { return []string{"exists"} }
+func (*pnMigrationTestRows) Close() error { return nil }
+func (r *pnMigrationTestRows) Next(values []driver.Value) error {
+ if r.read {
+ return io.EOF
+ }
+ r.read = true
+ values[0] = false
+ return nil
+}
+
+type contactUsernameRaceDB struct {
+ once sync.Once
+ entered chan struct{}
+ release chan struct{}
+}
+
+type contactUsernameRaceConnector struct{ state *contactUsernameRaceDB }
+
+func (connector *contactUsernameRaceConnector) Connect(context.Context) (driver.Conn, error) {
+ return &contactUsernameRaceConn{state: connector.state}, nil
+}
+
+func (*contactUsernameRaceConnector) Driver() driver.Driver { return pnMigrationTestDriver{} }
+
+type contactUsernameRaceConn struct{ state *contactUsernameRaceDB }
+
+func (*contactUsernameRaceConn) Prepare(string) (driver.Stmt, error) {
+ return nil, errors.New("unexpected prepare")
+}
+
+func (*contactUsernameRaceConn) Close() error { return nil }
+func (*contactUsernameRaceConn) Begin() (driver.Tx, error) {
+ return contactUsernameRaceTx{}, nil
+}
+
+func (conn *contactUsernameRaceConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
+ conn.state.once.Do(func() {
+ close(conn.state.entered)
+ <-conn.state.release
+ })
+ return driver.RowsAffected(1), nil
+}
+
+type contactUsernameRaceTx struct{}
+
+func (contactUsernameRaceTx) Commit() error { return nil }
+func (contactUsernameRaceTx) Rollback() error { return nil }
+
+type ensureIdentityConnector struct {
+ key [32]byte
+}
+
+func (connector *ensureIdentityConnector) Connect(context.Context) (driver.Conn, error) {
+ return &ensureIdentityConn{key: connector.key}, nil
+}
+
+func (*ensureIdentityConnector) Driver() driver.Driver { return pnMigrationTestDriver{} }
+
+type ensureIdentityConn struct {
+ key [32]byte
+}
+
+func (*ensureIdentityConn) Prepare(string) (driver.Stmt, error) {
+ return nil, errors.New("unexpected prepare")
+}
+
+func (*ensureIdentityConn) Close() error { return nil }
+func (*ensureIdentityConn) Begin() (driver.Tx, error) {
+ return nil, errors.New("unexpected transaction")
+}
+func (*ensureIdentityConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
+ return driver.RowsAffected(0), nil
+}
+func (conn *ensureIdentityConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
+ return &ensureIdentityRows{key: conn.key}, nil
+}
+
+type ensureIdentityRows struct {
+ key [32]byte
+ read bool
+}
+
+func (*ensureIdentityRows) Columns() []string { return []string{"identity"} }
+func (*ensureIdentityRows) Close() error { return nil }
+func (rows *ensureIdentityRows) Next(values []driver.Value) error {
+ if rows.read {
+ return io.EOF
+ }
+ rows.read = true
+ values[0] = rows.key[:]
+ return nil
+}
+
func TestNewSQLStoreDefersCaches(t *testing.T) {
store := NewSQLStore(nil, types.JID{User: "123", Server: types.DefaultUserServer})
- if store.contactCache != nil || store.identityCache != nil || store.migratedPNSessionsCache != nil || store.migratingPNSessions != nil {
+ if store.contactCache != nil || store.identityCache != nil || store.migratedPNSessionsCache != nil || store.emptyPNMigrationCache != nil || store.migratingPNSessions != nil {
t.Fatal("SQL store allocated caches before use")
}
}
@@ -22,6 +168,20 @@ func TestBuildSharedMassInsertQuery(t *testing.T) {
}
}
+func TestBulkContactNamesPreserveMissingUsername(t *testing.T) {
+ withUsername, withoutUsername := splitContactNameEntries([]store.ContactEntry{
+ {JID: types.NewJID("100", types.HiddenUserServer)},
+ {JID: types.NewJID("101", types.HiddenUserServer), Username: "named"},
+ {JID: types.NewJID("102", types.HiddenUserServer), UsernameSet: true},
+ })
+ if len(withoutUsername) != 1 || withoutUsername[0].JID.User != "100" {
+ t.Fatalf("name-only contacts = %#v", withoutUsername)
+ }
+ if len(withUsername) != 2 || withUsername[0].Username != "named" || !withUsername[1].UsernameSet || withUsername[1].Username != "" {
+ t.Fatalf("username-aware contacts = %#v", withUsername)
+ }
+}
+
func TestIdentityCacheIsBounded(t *testing.T) {
store := &SQLStore{identityCache: make(map[string]identityCacheEntry, maxIdentityCacheEntries)}
for i := 0; i < maxIdentityCacheEntries; i++ {
@@ -37,6 +197,109 @@ func TestIdentityCacheIsBounded(t *testing.T) {
}
}
+func TestCacheFetchedIdentitiesPreservesConcurrentWrites(t *testing.T) {
+ address := "100000011111111_1:7"
+ oldKey := [32]byte{1}
+ newKey := [32]byte{2}
+ store := &SQLStore{identityCache: map[string]identityCacheEntry{
+ address: {Key: newKey, Present: true},
+ }}
+ result := map[string][32]byte{address: oldKey}
+ store.cacheFetchedIdentities(result, map[string]identityCacheEntry{
+ address: {Key: oldKey, Present: true},
+ })
+
+ if got := store.identityCache[address]; !got.Present || got.Key != newKey {
+ t.Fatalf("identity cache was overwritten with stale query result: %#v", got)
+ }
+ if result[address] != newKey {
+ t.Fatalf("returned identity = %x, want concurrent value %x", result[address], newKey)
+ }
+}
+
+func TestDeleteIdentityLeavesNegativeCacheEntry(t *testing.T) {
+ state := &contactUsernameRaceDB{entered: make(chan struct{}), release: make(chan struct{})}
+ close(state.release)
+ db := sql.OpenDB(&contactUsernameRaceConnector{state: state})
+ t.Cleanup(func() { _ = db.Close() })
+ address := "100000011111111_1:7"
+ sqlStore := NewSQLStore(NewWithDB(db, "postgres", nil), types.NewJID("15550000000", types.DefaultUserServer))
+ sqlStore.identityCache = map[string]identityCacheEntry{address: {Key: [32]byte{1}, Present: true}}
+
+ if err := sqlStore.DeleteIdentity(context.Background(), address); err != nil {
+ t.Fatal(err)
+ }
+ entry, exists := sqlStore.identityCache[address]
+ if !exists || entry.Present {
+ t.Fatalf("identity deletion did not leave a negative cache entry: %#v", entry)
+ }
+}
+
+func TestSignalAddressRangeTreatsDomainSuffixLiterally(t *testing.T) {
+ lower, upper := signalAddressRange("123_1")
+ if lower != "123_1:" || upper != "123_1;" {
+ t.Fatalf("signal address range = [%q, %q)", lower, upper)
+ }
+ for name, query := range map[string]string{
+ "identity": deleteAllIdentitiesQuery,
+ "session": deleteAllSessionsQuery,
+ } {
+ if strings.Contains(query, "LIKE") || !strings.Contains(query, "their_id >= $2 AND their_id < $3") {
+ t.Fatalf("%s deletion is not a literal prefix range: %s", name, query)
+ }
+ }
+}
+
+func TestEnsureIdentityRejectsDifferentCachedKey(t *testing.T) {
+ address := "100000011111111_1:7"
+ storedKey := [32]byte{1}
+ sqlStore := &SQLStore{identityCache: map[string]identityCacheEntry{
+ address: {Key: storedKey, Present: true},
+ }}
+
+ trusted, err := sqlStore.EnsureIdentity(context.Background(), address, [32]byte{2}, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if trusted {
+ t.Fatal("different cached identity was trusted")
+ }
+ if got := sqlStore.identityCache[address]; !got.Present || got.Key != storedKey {
+ t.Fatalf("stored identity was overwritten: %#v", got)
+ }
+}
+
+func TestEnsureIdentityDoesNotOverwriteDatabaseKeyAfterNegativeCache(t *testing.T) {
+ address := "100000011111111_1:7"
+ storedKey := [32]byte{1}
+ db := sql.OpenDB(&ensureIdentityConnector{key: storedKey})
+ t.Cleanup(func() { _ = db.Close() })
+ sqlStore := NewSQLStore(NewWithDB(db, "postgres", nil), types.NewJID("15550000000", types.DefaultUserServer))
+ sqlStore.identityCache = map[string]identityCacheEntry{address: {}}
+
+ trusted, err := sqlStore.EnsureIdentity(context.Background(), address, [32]byte{2}, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if trusted {
+ t.Fatal("different database identity was trusted")
+ }
+ if got := sqlStore.identityCache[address]; !got.Present || got.Key != storedKey {
+ t.Fatalf("database identity was not retained: %#v", got)
+ }
+}
+
+func TestEnsureIdentityRejectsFetchStartedBeforeDeletion(t *testing.T) {
+ sqlStore := &SQLStore{identityDeleteGen: 2}
+ trusted, err := sqlStore.EnsureIdentity(context.Background(), "100000011111111_1:7", [32]byte{1}, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if trusted {
+ t.Fatal("identity fetched before deletion was trusted")
+ }
+}
+
func TestContactCacheIsBounded(t *testing.T) {
store := &SQLStore{contactCache: make(map[types.JID]*types.ContactInfo, maxContactCacheEntries)}
for i := 0; i < maxContactCacheEntries; i++ {
@@ -53,3 +316,124 @@ func TestContactCacheIsBounded(t *testing.T) {
t.Fatal("new contact was not cached")
}
}
+
+func TestPutManyContactUsernamesSerializesSingleWrites(t *testing.T) {
+ state := &contactUsernameRaceDB{entered: make(chan struct{}), release: make(chan struct{})}
+ db := sql.OpenDB(&contactUsernameRaceConnector{state: state})
+ t.Cleanup(func() { _ = db.Close() })
+ jid := types.NewJID("100000011111111", types.HiddenUserServer)
+ sqlStore := NewSQLStore(NewWithDB(db, "postgres", nil), types.NewJID("15550000000", types.DefaultUserServer))
+ sqlStore.contactCache = map[types.JID]*types.ContactInfo{jid: {Found: true, Username: "initial"}}
+
+ batchDone := make(chan error, 1)
+ go func() {
+ batchDone <- sqlStore.PutManyContactUsernames(context.Background(), []store.ContactUsernameEntry{{JID: jid, Username: "batch"}})
+ }()
+ <-state.entered
+ singleDone := make(chan error, 1)
+ go func() {
+ singleDone <- sqlStore.PutContactUsername(context.Background(), jid, "single")
+ }()
+ select {
+ case err := <-singleDone:
+ t.Fatalf("single write overtook batch write: %v", err)
+ case <-time.After(50 * time.Millisecond):
+ }
+ close(state.release)
+ if err := <-batchDone; err != nil {
+ t.Fatal(err)
+ }
+ if err := <-singleDone; err != nil {
+ t.Fatal(err)
+ }
+ if got := sqlStore.contactCache[jid].Username; got != "single" {
+ t.Fatalf("cached username = %q, want single", got)
+ }
+}
+
+func TestDeduplicateContactUsernamesUsesLastAlias(t *testing.T) {
+ firstJID := types.NewJID("100000011111111", types.HiddenUserServer)
+ secondJID := types.NewJID("100000022222222", types.HiddenUserServer)
+ entries := deduplicateContactUsernamesLastWriteWins([]store.ContactUsernameEntry{
+ {JID: firstJID, Username: "old"},
+ {JID: secondJID, Username: "other"},
+ {JID: firstJID, Username: "new"},
+ })
+ if len(entries) != 2 {
+ t.Fatalf("deduplicated entries = %#v", entries)
+ }
+ if entries[0].JID != firstJID || entries[0].Username != "new" {
+ t.Fatalf("first entry = %#v, want latest alias for %s", entries[0], firstJID)
+ }
+ if entries[1].JID != secondJID || entries[1].Username != "other" {
+ t.Fatalf("second entry = %#v", entries[1])
+ }
+}
+
+func TestMigratePNToLIDCachesEmptyPreflightTemporarily(t *testing.T) {
+ state := &pnMigrationTestDB{}
+ db := sql.OpenDB(&pnMigrationTestConnector{state: state})
+ t.Cleanup(func() { _ = db.Close() })
+
+ store := NewSQLStore(
+ NewWithDB(db, "postgres", nil),
+ types.NewJID("15550000000", types.DefaultUserServer),
+ )
+ pn := types.NewJID("15551234567", types.DefaultUserServer)
+ lid := types.NewJID("123456789012345", types.HiddenUserServer)
+
+ if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil {
+ t.Fatal(err)
+ }
+ if state.queries != 1 || state.begins != 0 {
+ t.Fatalf("unexpected database work: %d queries, %d transactions", state.queries, state.begins)
+ }
+ store.migratedPNSessionsCacheLock.Lock()
+ store.emptyPNMigrationCache[pn.SignalAddressUser()] = time.Now().Add(-time.Second)
+ store.migratedPNSessionsCacheLock.Unlock()
+ if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil {
+ t.Fatal(err)
+ }
+ if state.queries != 2 {
+ t.Fatalf("expired empty migration cache suppressed a query: %d", state.queries)
+ }
+ for _, table := range []string{"whatsmeow_sessions", "whatsmeow_identity_keys", "whatsmeow_sender_keys"} {
+ if !strings.Contains(state.query, table) {
+ t.Fatalf("existence query did not cover %s", table)
+ }
+ }
+ wantArgs := []string{store.JID, "15551234567:%"}
+ if len(state.args) != len(wantArgs) {
+ t.Fatalf("unexpected existence query argument count %d", len(state.args))
+ }
+ for i, want := range wantArgs {
+ if got := fmt.Sprint(state.args[i].Value); got != want {
+ t.Fatalf("existence query argument %d = %q, want %q", i, got, want)
+ }
+ }
+}
+
+func TestMigratePNToLIDUsesSQLitePrefixRange(t *testing.T) {
+ state := &pnMigrationTestDB{}
+ db := sql.OpenDB(&pnMigrationTestConnector{state: state})
+ t.Cleanup(func() { _ = db.Close() })
+ store := NewSQLStore(NewWithDB(db, "sqlite3", nil), types.NewJID("15550000000", types.DefaultUserServer))
+ pn := types.NewJID("15551234567", types.DefaultUserServer)
+ lid := types.NewJID("123456789012345", types.HiddenUserServer)
+
+ if err := store.MigratePNToLID(context.Background(), pn, lid); err != nil {
+ t.Fatal(err)
+ }
+ wantArgs := []string{store.JID, "15551234567:", "15551234567;"}
+ if len(state.args) != len(wantArgs) {
+ t.Fatalf("SQLite preflight argument count = %d, want %d", len(state.args), len(wantArgs))
+ }
+ for index, want := range wantArgs {
+ if got := fmt.Sprint(state.args[index].Value); got != want {
+ t.Fatalf("SQLite preflight argument %d = %q, want %q", index, got, want)
+ }
+ }
+}
diff --git a/store/sqlstore/upgrades/00-latest-schema.sql b/store/sqlstore/upgrades/00-latest-schema.sql
index c9db7237e..0d9e4c51e 100644
--- a/store/sqlstore/upgrades/00-latest-schema.sql
+++ b/store/sqlstore/upgrades/00-latest-schema.sql
@@ -1,4 +1,4 @@
--- v0 -> v15 (compatible with v8+): Latest schema
+-- v0 -> v18 (compatible with v8+): Latest schema
CREATE TABLE whatsmeow_device (
jid TEXT PRIMARY KEY,
lid TEXT,
@@ -37,6 +37,8 @@ CREATE TABLE whatsmeow_identity_keys (
PRIMARY KEY (our_jid, their_id),
FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE
);
+-- only: postgres
+CREATE INDEX whatsmeow_identity_keys_their_pattern_idx ON whatsmeow_identity_keys (our_jid, their_id text_pattern_ops);
CREATE TABLE whatsmeow_pre_keys (
jid TEXT,
@@ -56,6 +58,8 @@ CREATE TABLE whatsmeow_sessions (
PRIMARY KEY (our_jid, their_id),
FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE
);
+-- only: postgres
+CREATE INDEX whatsmeow_sessions_their_pattern_idx ON whatsmeow_sessions (our_jid, their_id text_pattern_ops);
CREATE TABLE whatsmeow_sender_keys (
our_jid TEXT,
@@ -66,6 +70,9 @@ CREATE TABLE whatsmeow_sender_keys (
PRIMARY KEY (our_jid, chat_id, sender_id),
FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE
);
+CREATE INDEX whatsmeow_sender_keys_sender_idx ON whatsmeow_sender_keys (our_jid, sender_id);
+-- only: postgres
+CREATE INDEX whatsmeow_sender_keys_sender_pattern_idx ON whatsmeow_sender_keys (our_jid, sender_id text_pattern_ops);
CREATE TABLE whatsmeow_app_state_sync_keys (
jid TEXT,
@@ -107,6 +114,7 @@ CREATE TABLE whatsmeow_contacts (
push_name TEXT,
business_name TEXT,
redacted_phone TEXT,
+ username TEXT,
PRIMARY KEY (our_jid, their_jid),
FOREIGN KEY (our_jid) REFERENCES whatsmeow_device(jid) ON DELETE CASCADE ON UPDATE CASCADE
diff --git a/store/sqlstore/upgrades/16-sender-key-migration-index.sql b/store/sqlstore/upgrades/16-sender-key-migration-index.sql
new file mode 100644
index 000000000..fb3cfb9c9
--- /dev/null
+++ b/store/sqlstore/upgrades/16-sender-key-migration-index.sql
@@ -0,0 +1,2 @@
+-- v16 (compatible with v8+): Index PN-addressed sender keys for LID migration checks
+CREATE INDEX whatsmeow_sender_keys_sender_idx ON whatsmeow_sender_keys (our_jid, sender_id);
diff --git a/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql b/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql
new file mode 100644
index 000000000..8a1575eeb
--- /dev/null
+++ b/store/sqlstore/upgrades/17-pn-migration-pattern-indexes.sql
@@ -0,0 +1,7 @@
+-- v17 (compatible with v8+): Index PN migration prefix lookups on PostgreSQL
+-- only: postgres
+CREATE INDEX whatsmeow_identity_keys_their_pattern_idx ON whatsmeow_identity_keys (our_jid, their_id text_pattern_ops);
+-- only: postgres
+CREATE INDEX whatsmeow_sessions_their_pattern_idx ON whatsmeow_sessions (our_jid, their_id text_pattern_ops);
+-- only: postgres
+CREATE INDEX whatsmeow_sender_keys_sender_pattern_idx ON whatsmeow_sender_keys (our_jid, sender_id text_pattern_ops);
diff --git a/store/sqlstore/upgrades/18-contact-username.sql b/store/sqlstore/upgrades/18-contact-username.sql
new file mode 100644
index 000000000..6d31cec7f
--- /dev/null
+++ b/store/sqlstore/upgrades/18-contact-username.sql
@@ -0,0 +1,2 @@
+-- v18 (compatible with v8+): Persist the optional WhatsApp username alongside the LID-keyed contact.
+ALTER TABLE whatsmeow_contacts ADD COLUMN username TEXT;
diff --git a/store/sqlstore/upgrades/upgrades_test.go b/store/sqlstore/upgrades/upgrades_test.go
new file mode 100644
index 000000000..e1bbe4e79
--- /dev/null
+++ b/store/sqlstore/upgrades/upgrades_test.go
@@ -0,0 +1,166 @@
+package upgrades
+
+import (
+ "bufio"
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+
+ "go.mau.fi/util/dbutil"
+)
+
+var upgradeVersionPattern = regexp.MustCompile(`^-- (?:v(\d+) -> )?v(\d+)`)
+
+func TestUpgradeSourcesHaveUniqueStartingVersions(t *testing.T) {
+ entries, err := fs.ReadDir(upgrades, ".")
+ if err != nil {
+ t.Fatal(err)
+ }
+ versions := make(map[int]string)
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
+ continue
+ }
+ file, err := upgrades.Open(entry.Name())
+ if err != nil {
+ t.Fatal(err)
+ }
+ scanner := bufio.NewScanner(file)
+ if !scanner.Scan() {
+ _ = file.Close()
+ t.Fatalf("%s has no upgrade header", entry.Name())
+ }
+ _ = file.Close()
+ matches := upgradeVersionPattern.FindStringSubmatch(scanner.Text())
+ if matches == nil {
+ t.Fatalf("%s has an invalid upgrade header", entry.Name())
+ }
+ to, err := strconv.Atoi(matches[2])
+ if err != nil {
+ t.Fatal(err)
+ }
+ from := to - 1
+ if matches[1] != "" {
+ from, err = strconv.Atoi(matches[1])
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if previous, exists := versions[from]; exists {
+ t.Fatalf("%s and %s both register an upgrade starting at v%d", previous, entry.Name(), from)
+ }
+ versions[from] = entry.Name()
+ }
+}
+
+type upgradeTestState struct {
+ executed []string
+ version int64
+ compatVersion int64
+}
+
+type upgradeTestConnector struct{ state *upgradeTestState }
+
+func (c *upgradeTestConnector) Connect(context.Context) (driver.Conn, error) {
+ return &upgradeTestConn{state: c.state}, nil
+}
+
+func (*upgradeTestConnector) Driver() driver.Driver { return upgradeTestDriver{} }
+
+type upgradeTestDriver struct{}
+
+func (upgradeTestDriver) Open(string) (driver.Conn, error) {
+ return nil, errors.New("use connector")
+}
+
+type upgradeTestConn struct{ state *upgradeTestState }
+
+func (*upgradeTestConn) Prepare(string) (driver.Stmt, error) {
+ return nil, errors.New("unexpected prepare")
+}
+
+func (*upgradeTestConn) Close() error { return nil }
+
+func (c *upgradeTestConn) Begin() (driver.Tx, error) { return &upgradeTestTx{}, nil }
+
+func (c *upgradeTestConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
+ return &upgradeTestTx{}, nil
+}
+
+func (c *upgradeTestConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
+ switch {
+ case strings.Contains(query, "information_schema.columns"):
+ return &upgradeTestRows{columns: []string{"exists"}, values: []driver.Value{true}}, nil
+ case strings.HasPrefix(query, "SELECT version, compat FROM whatsmeow_version"):
+ return &upgradeTestRows{columns: []string{"version", "compat"}, values: []driver.Value{c.state.version, c.state.compatVersion}}, nil
+ default:
+ return nil, fmt.Errorf("unexpected query: %s", query)
+ }
+}
+
+func (c *upgradeTestConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
+ c.state.executed = append(c.state.executed, query)
+ if strings.HasPrefix(query, "INSERT INTO whatsmeow_version") {
+ if len(args) != 2 {
+ return nil, fmt.Errorf("unexpected version argument count: %d", len(args))
+ }
+ c.state.version = args[0].Value.(int64)
+ c.state.compatVersion = args[1].Value.(int64)
+ }
+ return driver.RowsAffected(1), nil
+}
+
+type upgradeTestTx struct{}
+
+func (*upgradeTestTx) Commit() error { return nil }
+func (*upgradeTestTx) Rollback() error { return nil }
+
+type upgradeTestRows struct {
+ columns []string
+ values []driver.Value
+ read bool
+}
+
+func (r *upgradeTestRows) Columns() []string { return r.columns }
+func (*upgradeTestRows) Close() error { return nil }
+func (r *upgradeTestRows) Next(values []driver.Value) error {
+ if r.read {
+ return io.EOF
+ }
+ r.read = true
+ copy(values, r.values)
+ return nil
+}
+
+func TestUpgradeFromCurrentDevSchemaAddsUsername(t *testing.T) {
+ state := &upgradeTestState{version: 17, compatVersion: 8}
+ rawDB := sql.OpenDB(&upgradeTestConnector{state: state})
+ t.Cleanup(func() { _ = rawDB.Close() })
+ db, err := dbutil.NewWithDB(rawDB, "postgres")
+ if err != nil {
+ t.Fatal(err)
+ }
+ db.VersionTable = "whatsmeow_version"
+ db.UpgradeTable = Table
+ if err = db.Upgrade(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if state.version != 18 || state.compatVersion != 8 {
+ t.Fatalf("schema version = %d/%d, want 18/8", state.version, state.compatVersion)
+ }
+ want := "ALTER TABLE whatsmeow_contacts ADD COLUMN username TEXT;"
+ for _, query := range state.executed {
+ if strings.TrimSpace(query) == want {
+ return
+ }
+ }
+ t.Fatalf("username migration was not executed: %q", state.executed)
+}
diff --git a/store/store.go b/store/store.go
index 6ed41c6ce..1cefa7c93 100644
--- a/store/store.go
+++ b/store/store.go
@@ -10,14 +10,15 @@ package store
import (
"context"
"errors"
+ "sync"
"time"
"github.com/google/uuid"
- "go.mau.fi/whatsmeow/proto/waAdv"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/util/keys"
- waLog "go.mau.fi/whatsmeow/util/log"
+ "github.com/polymorfa/hypermeow/proto/waAdv"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/util/keys"
+ waLog "github.com/polymorfa/hypermeow/util/log"
)
type IdentityStore interface {
@@ -27,6 +28,11 @@ type IdentityStore interface {
IsTrustedIdentity(ctx context.Context, address string, key [32]byte) (bool, error)
}
+type IdentityKeyReader interface {
+ GetManyIdentities(ctx context.Context, addresses []string) (map[string][32]byte, uint64, error)
+ EnsureIdentity(ctx context.Context, address string, key [32]byte, deleteGeneration uint64) (bool, error)
+}
+
type SessionStore interface {
GetSession(ctx context.Context, address string) ([]byte, error)
HasSession(ctx context.Context, address string) (bool, error)
@@ -81,13 +87,15 @@ type AppStateStore interface {
}
type ContactEntry struct {
- JID types.JID
- FirstName string
- FullName string
+ JID types.JID
+ FirstName string
+ FullName string
+ Username string
+ UsernameSet bool
}
-func (ce ContactEntry) GetMassInsertValues() [3]any {
- return [...]any{ce.JID.String(), ce.FirstName, ce.FullName}
+func (ce ContactEntry) GetMassInsertValues() [4]any {
+ return [...]any{ce.JID.String(), ce.FirstName, ce.FullName, ce.Username}
}
type RedactedPhoneEntry struct {
@@ -109,6 +117,23 @@ type ContactStore interface {
GetAllContacts(ctx context.Context) (map[types.JID]types.ContactInfo, error)
}
+type ContactUsernameStore interface {
+ PutContactUsername(ctx context.Context, user types.JID, username string) error
+}
+
+type ContactUsernameBatchStore interface {
+ PutManyContactUsernames(ctx context.Context, entries []ContactUsernameEntry) error
+}
+
+type ContactUsernameEntry struct {
+ JID types.JID
+ Username string
+}
+
+func (cue ContactUsernameEntry) GetMassInsertValues() [2]any {
+ return [...]any{cue.JID.String(), cue.Username}
+}
+
var MutedForever = time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC)
type ChatSettingsStore interface {
@@ -190,6 +215,10 @@ type LIDStore interface {
GetManyLIDsForPNs(ctx context.Context, pns []types.JID) (map[types.JID]types.JID, error)
}
+type LIDBatchReverseStore interface {
+ GetManyPNsForLIDs(ctx context.Context, lids []types.JID) (map[types.JID]types.JID, error)
+}
+
type AllSessionSpecificStores interface {
IdentityStore
SessionStore
@@ -252,6 +281,9 @@ type Device struct {
EventBuffer EventBuffer
LIDs LIDStore
Container DeviceContainer
+
+ saveDeleteLockInit sync.Once
+ saveDeleteLock chan struct{}
}
func (device *Device) GetJID() types.JID {
@@ -274,7 +306,32 @@ func (device *Device) GetLID() types.JID {
var ErrDeviceDeleted = errors.New("invalid use of deleted device")
+func (device *Device) lockSaveDelete(ctx context.Context) error {
+ device.saveDeleteLockInit.Do(func() {
+ device.saveDeleteLock = make(chan struct{}, 1)
+ device.saveDeleteLock <- struct{}{}
+ })
+ select {
+ case <-device.saveDeleteLock:
+ if err := ctx.Err(); err != nil {
+ device.unlockSaveDelete()
+ return err
+ }
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func (device *Device) unlockSaveDelete() {
+ device.saveDeleteLock <- struct{}{}
+}
+
func (device *Device) Save(ctx context.Context) error {
+ if err := device.lockSaveDelete(ctx); err != nil {
+ return err
+ }
+ defer device.unlockSaveDelete()
if device.Deleted {
return ErrDeviceDeleted
}
@@ -282,6 +339,10 @@ func (device *Device) Save(ctx context.Context) error {
}
func (device *Device) Delete(ctx context.Context) error {
+ if err := device.lockSaveDelete(ctx); err != nil {
+ return err
+ }
+ defer device.unlockSaveDelete()
if device.Deleted {
return nil
}
diff --git a/store/store_test.go b/store/store_test.go
new file mode 100644
index 000000000..4ed5d3227
--- /dev/null
+++ b/store/store_test.go
@@ -0,0 +1,157 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/polymorfa/hypermeow/types"
+)
+
+type blockingDeviceContainer struct {
+ putStarted chan struct{}
+ allowPut chan struct{}
+ deleteStarted chan struct{}
+}
+
+type legacyLIDStore struct{}
+
+func (*legacyLIDStore) PutManyLIDMappings(context.Context, []LIDMapping) error { return nil }
+func (*legacyLIDStore) PutLIDMapping(context.Context, types.JID, types.JID) error {
+ return nil
+}
+func (*legacyLIDStore) GetPNForLID(context.Context, types.JID) (types.JID, error) {
+ return types.EmptyJID, nil
+}
+func (*legacyLIDStore) GetLIDForPN(context.Context, types.JID) (types.JID, error) {
+ return types.EmptyJID, nil
+}
+func (*legacyLIDStore) GetManyLIDsForPNs(context.Context, []types.JID) (map[types.JID]types.JID, error) {
+ return nil, nil
+}
+
+var _ LIDStore = (*legacyLIDStore)(nil)
+var _ LIDBatchReverseStore = (*NoopStore)(nil)
+
+func (c *blockingDeviceContainer) PutDevice(_ context.Context, device *Device) error {
+ close(c.putStarted)
+ <-c.allowPut
+ if device.ID == nil {
+ return errors.New("device ID cleared during save")
+ }
+ return nil
+}
+
+func (c *blockingDeviceContainer) DeleteDevice(context.Context, *Device) error {
+ close(c.deleteStarted)
+ return nil
+}
+
+func TestDeviceDeleteWaitsForSave(t *testing.T) {
+ container := &blockingDeviceContainer{
+ putStarted: make(chan struct{}),
+ allowPut: make(chan struct{}),
+ deleteStarted: make(chan struct{}),
+ }
+ id := types.NewJID("15551234567", types.DefaultUserServer)
+ device := &Device{ID: &id, Container: container}
+ saveDone := make(chan error, 1)
+ deleteDone := make(chan error, 1)
+
+ go func() { saveDone <- device.Save(context.Background()) }()
+ <-container.putStarted
+ go func() { deleteDone <- device.Delete(context.Background()) }()
+
+ select {
+ case <-container.deleteStarted:
+ close(container.allowPut)
+ <-saveDone
+ <-deleteDone
+ t.Fatal("delete entered the container while save was active")
+ case <-time.After(50 * time.Millisecond):
+ }
+
+ close(container.allowPut)
+ if err := <-saveDone; err != nil {
+ t.Fatal(err)
+ }
+ if err := <-deleteDone; err != nil {
+ t.Fatal(err)
+ }
+ if !device.Deleted || device.ID != nil {
+ t.Fatalf("device was not deleted: deleted=%t id=%v", device.Deleted, device.ID)
+ }
+}
+
+func TestDeviceDeleteWaitObservesCancellation(t *testing.T) {
+ container := &blockingDeviceContainer{
+ putStarted: make(chan struct{}),
+ allowPut: make(chan struct{}),
+ deleteStarted: make(chan struct{}),
+ }
+ id := types.NewJID("15551234567", types.DefaultUserServer)
+ device := &Device{ID: &id, Container: container}
+ saveDone := make(chan error, 1)
+ go func() { saveDone <- device.Save(context.Background()) }()
+ <-container.putStarted
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ deleteDone := make(chan error, 1)
+ go func() { deleteDone <- device.Delete(ctx) }()
+ select {
+ case err := <-deleteDone:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("delete error = %v, want context canceled", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("canceled delete remained blocked")
+ }
+ select {
+ case <-container.deleteStarted:
+ t.Fatal("canceled delete entered the container")
+ default:
+ }
+ close(container.allowPut)
+ if err := <-saveDone; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDeviceLockRejectsCanceledContextWhenAvailable(t *testing.T) {
+ device := &Device{}
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ for range 100 {
+ err := device.lockSaveDelete(ctx)
+ if err == nil {
+ device.unlockSaveDelete()
+ t.Fatal("canceled context acquired the save/delete lock")
+ }
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("lock error = %v, want context canceled", err)
+ }
+ }
+}
+
+func TestDeviceSaveAfterDeleteFailsWithoutContainerWrite(t *testing.T) {
+ container := &blockingDeviceContainer{
+ putStarted: make(chan struct{}),
+ allowPut: make(chan struct{}),
+ deleteStarted: make(chan struct{}),
+ }
+ close(container.allowPut)
+ id := types.NewJID("15551234567", types.DefaultUserServer)
+ device := &Device{ID: &id, Container: container}
+ if err := device.Delete(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if err := device.Save(context.Background()); !errors.Is(err, ErrDeviceDeleted) {
+ t.Fatalf("save error = %v, want %v", err, ErrDeviceDeleted)
+ }
+ select {
+ case <-container.putStarted:
+ t.Fatal("save wrote to the container after delete")
+ default:
+ }
+}
diff --git a/tctoken.go b/tctoken.go
index fc710c84a..2a812a11c 100644
--- a/tctoken.go
+++ b/tctoken.go
@@ -11,8 +11,8 @@ import (
"fmt"
"time"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
)
const (
diff --git a/types/business_account.go b/types/business_account.go
new file mode 100644
index 000000000..e57743856
--- /dev/null
+++ b/types/business_account.go
@@ -0,0 +1,63 @@
+package types
+
+type BusinessLinkedAccounts struct {
+ FacebookPage *BusinessFacebookPage `json:"facebook_page,omitempty"`
+ FacebookBusiness *BusinessFacebookBusiness `json:"facebook_business,omitempty"`
+ InstagramProfessional *BusinessInstagramProfessional `json:"instagram_professional,omitempty"`
+ WhatsAppAdIdentity *BusinessWhatsAppAdIdentity `json:"whatsapp_ad_identity,omitempty"`
+}
+
+type BusinessFacebookPage struct {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name"`
+ ProfileSync string `json:"profile_sync,omitempty"`
+ HasActiveCTWAAd bool `json:"has_active_ctwa_ad"`
+ HasCreatedAd bool `json:"has_created_ad"`
+ ProfilePictureURL string `json:"profile_picture_url"`
+ ShowOnProfile bool `json:"show_on_profile"`
+ WhatsAppAsPageButton bool `json:"whatsapp_as_page_button"`
+}
+
+type BusinessFacebookBusiness struct {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name"`
+ CatalogID string `json:"catalog_id,omitempty"`
+ CatalogState string `json:"catalog_state,omitempty"`
+}
+
+type BusinessInstagramProfessional struct {
+ Handle string `json:"handle"`
+ DisplayName string `json:"display_name"`
+ ProfilePictureURL string `json:"profile_picture_url"`
+ ShowOnProfile bool `json:"show_on_profile"`
+}
+
+type BusinessWhatsAppAdIdentity struct {
+ ID string `json:"id"`
+ HasActiveCTWAAd bool `json:"has_active_ctwa_ad"`
+ HasCreatedAd bool `json:"has_created_ad"`
+}
+
+type BusinessFeature string
+
+const (
+ BusinessFeatureMetaVerified BusinessFeature = "meta_verified"
+ BusinessFeatureMarketingMessages BusinessFeature = "marketing_messages"
+ BusinessFeatureGenAI BusinessFeature = "genai"
+ BusinessFeatureGenAIImage BusinessFeature = "genai_image"
+ BusinessFeatureMetaOne BusinessFeature = "meta_one"
+ BusinessFeatureBBPro BusinessFeature = "bb_pro"
+)
+
+type BusinessFeatureEligibility struct {
+ Feature BusinessFeature `json:"feature"`
+ Status string `json:"status"`
+ Expiration int64 `json:"expiration,omitempty"`
+ AdditionalParams string `json:"additional_params,omitempty"`
+ ShowPrivacyInterstitial *bool `json:"show_privacy_interstitial_to_new_users,omitempty"`
+ V1Enabled *bool `json:"v1_enabled,omitempty"`
+}
+
+type BusinessEligibility struct {
+ Features []BusinessFeatureEligibility `json:"features"`
+}
diff --git a/types/business_catalog.go b/types/business_catalog.go
new file mode 100644
index 000000000..4ef73196d
--- /dev/null
+++ b/types/business_catalog.go
@@ -0,0 +1,208 @@
+package types
+
+type BusinessCatalogPage struct {
+ Next string `json:"next,omitempty"`
+ Previous string `json:"previous,omitempty"`
+ Products []BusinessProduct `json:"products"`
+}
+
+type BusinessProduct struct {
+ ID string `json:"id"`
+ RetailerID string `json:"retailer_id,omitempty"`
+ BelongsTo string `json:"belongs_to,omitempty"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Price string `json:"price"`
+ Currency string `json:"currency"`
+ URL string `json:"url,omitempty"`
+ ShimmedURL string `json:"shimmed_url,omitempty"`
+ Hidden bool `json:"is_hidden"`
+ Sanctioned bool `json:"is_sanctioned"`
+ MaxAvailable int `json:"max_available,omitempty"`
+ Availability string `json:"product_availability,omitempty"`
+ ComplianceCategory string `json:"compliance_category,omitempty"`
+ Compliance *BusinessComplianceInfo `json:"compliance_info,omitempty"`
+ Media BusinessProductMedia `json:"media"`
+ SalePrice *BusinessSalePrice `json:"sale_price,omitempty"`
+ Status BusinessProductStatus `json:"status_info"`
+ VariantInfo *BusinessProductVariant `json:"variant_info,omitempty"`
+}
+
+type BusinessProductInput struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Currency string `json:"currency,omitempty"`
+ Price string `json:"price,omitempty"`
+ SalePrice string `json:"sale_price,omitempty"`
+ URL string `json:"url,omitempty"`
+ RetailerID string `json:"retailer_id,omitempty"`
+ Hidden bool `json:"is_hidden"`
+ ImageURLs []string `json:"image_urls"`
+ VideoURLs []string `json:"video_urls,omitempty"`
+ ComplianceCategory string `json:"compliance_category,omitempty"`
+ Compliance *BusinessComplianceInfo `json:"compliance_info,omitempty"`
+}
+
+type BusinessComplianceInfo struct {
+ CountryCodeOrigin string `json:"country_code_origin,omitempty"`
+ ImporterName string `json:"importer_name,omitempty"`
+ ImporterAddress *BusinessAddress `json:"importer_address,omitempty"`
+}
+
+type BusinessMerchantEntityType string
+
+const (
+ BusinessMerchantEntitySoleProprietorship BusinessMerchantEntityType = "SOLE_PROPRIETORSHIP"
+ BusinessMerchantEntityPartnership BusinessMerchantEntityType = "PARTNERSHIP"
+ BusinessMerchantEntityPrivateCompany BusinessMerchantEntityType = "PRIVATE_COMPANY"
+ BusinessMerchantEntityPublicCompany BusinessMerchantEntityType = "PUBLIC_COMPANY"
+ BusinessMerchantEntityLimitedLiabilityPartnership BusinessMerchantEntityType = "LIMITED_LIABILITY_PARTNERSHIP"
+ BusinessMerchantEntityOther BusinessMerchantEntityType = "OTHER"
+)
+
+type BusinessMerchantContact struct {
+ Email string `json:"email"`
+ LandlineNumber string `json:"landline_number"`
+ MobileNumber string `json:"mobile_number"`
+}
+
+type BusinessMerchantOfficer struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ LandlineNumber string `json:"landline_number"`
+ MobileNumber string `json:"mobile_number"`
+}
+
+type BusinessMerchantCompliance struct {
+ EntityName string `json:"entity_name"`
+ EntityType BusinessMerchantEntityType `json:"entity_type"`
+ IsRegistered bool `json:"is_registered"`
+ EntityTypeCustom string `json:"entity_type_custom"`
+ CustomerCare BusinessMerchantContact `json:"customer_care_details"`
+ GrievanceOfficer BusinessMerchantOfficer `json:"grievance_officer_details"`
+}
+
+type BusinessAddress struct {
+ Street1 string `json:"street1,omitempty"`
+ Street2 string `json:"street2,omitempty"`
+ City string `json:"city,omitempty"`
+ Region string `json:"region,omitempty"`
+ PostalCode string `json:"postal_code,omitempty"`
+ CountryCode string `json:"country_code,omitempty"`
+}
+
+type BusinessProductMedia struct {
+ Images []BusinessProductImage `json:"images,omitempty"`
+ Videos []BusinessProductVideo `json:"videos,omitempty"`
+}
+
+type BusinessProductImage struct {
+ ID string `json:"id"`
+ OriginalURL string `json:"original_image_url,omitempty"`
+ RequestURL string `json:"request_image_url,omitempty"`
+}
+
+type BusinessProductVideo struct {
+ ID string `json:"id"`
+ OriginalURL string `json:"original_video_url,omitempty"`
+ ThumbnailURL string `json:"thumbnail_url,omitempty"`
+}
+
+type BusinessSalePrice struct {
+ Price string `json:"price"`
+ StartDate string `json:"start_date,omitempty"`
+ EndDate string `json:"end_date,omitempty"`
+}
+
+type BusinessProductStatus struct {
+ Status string `json:"status,omitempty"`
+ CanAppeal bool `json:"can_appeal,omitempty"`
+}
+
+type BusinessProductVariant struct {
+ Availability BusinessVariantAvailability `json:"availability,omitempty"`
+ ListingDetails BusinessVariantListing `json:"listing_details,omitempty"`
+ Types []BusinessVariantType `json:"types,omitempty"`
+ VariantProperties []BusinessVariantProperty `json:"variant_properties,omitempty"`
+}
+
+type BusinessVariantAvailability struct {
+ Listings []BusinessVariantAvailabilityItem `json:"listing,omitempty"`
+}
+
+type BusinessVariantAvailabilityItem struct {
+ ProductID string `json:"product_id,omitempty"`
+ Available bool `json:"is_available"`
+ Options []BusinessVariantProperty `json:"options,omitempty"`
+}
+
+type BusinessVariantListing struct {
+ Description string `json:"description,omitempty"`
+ LowestPrice string `json:"lowest_price,omitempty"`
+ MultiPrice string `json:"multi_price,omitempty"`
+}
+
+type BusinessVariantType struct {
+ Name string `json:"name"`
+ Options []BusinessVariantOption `json:"options,omitempty"`
+}
+
+type BusinessVariantOption struct {
+ Value string `json:"value"`
+ Thumbnail *BusinessVariantThumbnail `json:"thumbnail_media,omitempty"`
+}
+
+type BusinessVariantThumbnail struct {
+ ID string `json:"id,omitempty"`
+ OriginalURL string `json:"original_image_url,omitempty"`
+ RequestURL string `json:"request_image_url,omitempty"`
+ OriginalDimensions BusinessDimensions `json:"original_dimensions,omitempty"`
+}
+
+type BusinessDimensions struct {
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+}
+
+type BusinessVariantProperty struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+type BusinessCollectionPage struct {
+ Next string `json:"next,omitempty"`
+ Collections []BusinessCollection `json:"collections"`
+}
+
+type BusinessCollection struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Next string `json:"next,omitempty"`
+ Previous string `json:"previous,omitempty"`
+ Products []BusinessProduct `json:"products"`
+ Status BusinessCollectionStatus `json:"status_info"`
+}
+
+type BusinessCollectionStatus struct {
+ Status string `json:"status,omitempty"`
+ CanAppeal bool `json:"can_appeal,omitempty"`
+ CommerceURL string `json:"commerce_url,omitempty"`
+ RejectReason string `json:"reject_reason,omitempty"`
+}
+
+type BusinessCollectionUpdate struct {
+ Name *string `json:"name,omitempty"`
+ AddProductIDs []string `json:"add_product_ids,omitempty"`
+ RemoveProductIDs []string `json:"remove_product_ids,omitempty"`
+}
+
+type BusinessCollectionMutationResult struct {
+ ID string `json:"id"`
+ ReviewStatus string `json:"review_status"`
+}
+
+type BusinessCollectionMove struct {
+ CollectionID string `json:"collection_id"`
+ FromIndex int `json:"from_index"`
+ ToIndex int `json:"to_index"`
+}
diff --git a/types/events/appstate.go b/types/events/appstate.go
index a6082743b..b345067d1 100644
--- a/types/events/appstate.go
+++ b/types/events/appstate.go
@@ -9,9 +9,9 @@ package events
import (
"time"
- "go.mau.fi/whatsmeow/appstate"
- "go.mau.fi/whatsmeow/proto/waSyncAction"
- "go.mau.fi/whatsmeow/types"
+ "github.com/polymorfa/hypermeow/appstate"
+ "github.com/polymorfa/hypermeow/proto/waSyncAction"
+ "github.com/polymorfa/hypermeow/types"
)
// Contact is emitted when an entry in the user's contact list is modified from another device.
@@ -23,6 +23,15 @@ type Contact struct {
FromFullSync bool // Whether the action is emitted because of a fullSync
}
+// LIDContact is emitted when a LID-keyed contact is modified from another device.
+type LIDContact struct {
+ JID types.JID
+ Timestamp time.Time
+
+ Action *waSyncAction.LidContactAction
+ FromFullSync bool
+}
+
// PushName is emitted when a message is received with a different push name than the previous value cached for the same user.
type PushName struct {
JID types.JID // The user whose push name changed.
@@ -175,6 +184,15 @@ type LabelAssociationMessage struct {
FromFullSync bool // Whether the action is emitted because of a fullSync
}
+// QuickReply is emitted when a quick reply is changed from any device.
+type QuickReply struct {
+ Timestamp time.Time
+ ID string
+
+ Action *waSyncAction.QuickReplyAction
+ FromFullSync bool
+}
+
// AppState is emitted directly for new data received from app state syncing.
// You should generally use the higher-level events like events.Contact and events.Mute.
type AppState struct {
diff --git a/types/events/call.go b/types/events/call.go
index c1cf2b2c3..022c15cdc 100644
--- a/types/events/call.go
+++ b/types/events/call.go
@@ -7,8 +7,8 @@
package events
import (
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/types"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/types"
)
// CallOffer is emitted when the user receives a call on WhatsApp.
diff --git a/types/events/events.go b/types/events/events.go
index a9b1c48c8..ab2b1ece6 100644
--- a/types/events/events.go
+++ b/types/events/events.go
@@ -14,18 +14,18 @@ import (
"go.mau.fi/util/jsontime"
- waBinary "go.mau.fi/whatsmeow/binary"
- armadillo "go.mau.fi/whatsmeow/proto"
- "go.mau.fi/whatsmeow/proto/instamadilloTransportPayload"
- "go.mau.fi/whatsmeow/proto/waArmadilloApplication"
- "go.mau.fi/whatsmeow/proto/waCompanionReg"
- "go.mau.fi/whatsmeow/proto/waConsumerApplication"
- "go.mau.fi/whatsmeow/proto/waE2E"
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/proto/waMsgApplication"
- "go.mau.fi/whatsmeow/proto/waMsgTransport"
- "go.mau.fi/whatsmeow/proto/waWeb"
- "go.mau.fi/whatsmeow/types"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ armadillo "github.com/polymorfa/hypermeow/proto"
+ "github.com/polymorfa/hypermeow/proto/instamadilloTransportPayload"
+ "github.com/polymorfa/hypermeow/proto/waArmadilloApplication"
+ "github.com/polymorfa/hypermeow/proto/waCompanionReg"
+ "github.com/polymorfa/hypermeow/proto/waConsumerApplication"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/proto/waHistorySync"
+ "github.com/polymorfa/hypermeow/proto/waMsgApplication"
+ "github.com/polymorfa/hypermeow/proto/waMsgTransport"
+ "github.com/polymorfa/hypermeow/proto/waWeb"
+ "github.com/polymorfa/hypermeow/types"
)
// QR is emitted after connecting when there's no session data in the device store.
@@ -271,6 +271,7 @@ type HistorySync struct {
Data *waHistorySync.HistorySync
Notification *waE2E.HistorySyncNotification
+ MessageID types.MessageID
}
type DecryptFailMode string
diff --git a/types/group.go b/types/group.go
index 7de8df6af..925b6ff52 100644
--- a/types/group.go
+++ b/types/group.go
@@ -106,6 +106,7 @@ type GroupParticipant struct {
JID JID
PhoneNumber JID
LID JID
+ Username string
IsAdmin bool
IsSuperAdmin bool
diff --git a/types/newsletter.go b/types/newsletter.go
index 6f63ecf2f..147a65e6e 100644
--- a/types/newsletter.go
+++ b/types/newsletter.go
@@ -14,7 +14,7 @@ import (
"go.mau.fi/util/jsontime"
- "go.mau.fi/whatsmeow/proto/waE2E"
+ "github.com/polymorfa/hypermeow/proto/waE2E"
)
type NewsletterVerificationState string
diff --git a/types/user.go b/types/user.go
index 805f70e7e..1a7c0d061 100644
--- a/types/user.go
+++ b/types/user.go
@@ -11,7 +11,7 @@ import (
"go.mau.fi/util/jsontime"
- "go.mau.fi/whatsmeow/proto/waVnameCert"
+ "github.com/polymorfa/hypermeow/proto/waVnameCert"
)
// VerifiedName contains verified WhatsApp business details.
@@ -33,6 +33,7 @@ type UserInfo struct {
PictureID string
Devices []JID
LID JID
+ Username string
}
type BotListInfo struct {
@@ -77,6 +78,7 @@ type ContactInfo struct {
FullName string
PushName string
BusinessName string
+ Username string
// Only for LID members encountered in groups, the phone number in the form "+1∙∙∙∙∙∙∙∙80"
RedactedPhone string
}
@@ -97,10 +99,17 @@ type IsOnWhatsAppResponse struct {
IsIn bool // Whether the phone is registered or not.
PhoneNumber JID
+ Username string
VerifiedName *VerifiedName // If the phone is a business, the verified business details.
}
+type UsernameResolution struct {
+ LID JID
+ Username string
+ KeyRequired bool
+}
+
// BusinessMessageLinkTarget contains the info that is found using a business message link (see Client.ResolveBusinessMessageLink)
type BusinessMessageLinkTarget struct {
JID JID // The JID of the business.
@@ -211,6 +220,26 @@ type BusinessHoursConfig struct {
CloseTime string
}
+type BusinessHoursDay struct {
+ DayOfWeek string
+ Mode string
+ OpenTime int
+ CloseTime int
+}
+
+type BusinessHoursUpdate struct {
+ TimeZone string
+ Days []BusinessHoursDay
+}
+
+type BusinessProfileUpdate struct {
+ Address *string
+ Email *string
+ Description *string
+ Websites *[]string
+ Hours *BusinessHoursUpdate
+}
+
// Category contains a WhatsApp business category.
type Category struct {
ID string
@@ -222,6 +251,9 @@ type BusinessProfile struct {
JID JID
Address string
Email string
+ Description string
+ Websites []string
+ CoverPhotoID string
Categories []Category
ProfileOptions map[string]string
BusinessHoursTimeZone string
diff --git a/update.go b/update.go
index 7e43d9c75..e428c8b14 100644
--- a/update.go
+++ b/update.go
@@ -14,8 +14,8 @@ import (
"regexp"
"strconv"
- "go.mau.fi/whatsmeow/socket"
- "go.mau.fi/whatsmeow/store"
+ "github.com/polymorfa/hypermeow/socket"
+ "github.com/polymorfa/hypermeow/store"
)
var clientVersionRegex = regexp.MustCompile(`"client_revision":(\d+),`)
diff --git a/upload.go b/upload.go
index cd4f19350..0b4ab6a72 100644
--- a/upload.go
+++ b/upload.go
@@ -23,8 +23,8 @@ import (
"go.mau.fi/util/random"
- "go.mau.fi/whatsmeow/socket"
- "go.mau.fi/whatsmeow/util/cbcutil"
+ "github.com/polymorfa/hypermeow/socket"
+ "github.com/polymorfa/hypermeow/util/cbcutil"
)
// UploadResponse contains the data from the attachment upload, which can be put into a message to send the attachment.
@@ -308,8 +308,8 @@ func (cli *Client) DeleteMedia(ctx context.Context, appInfo MediaType, directPat
req.Header.Set("Origin", socket.Origin)
req.Header.Set("Referer", socket.Origin+"/")
- if cmn := cli.Store.CompanionMetaNonce; cmn != "" && encHandle != "" {
- req.Header.Set("Companion_User_Secret", cli.Store.CompanionMetaNonce)
+ if cmn := cli.currentCompanionMetaNonce(); cmn != "" && encHandle != "" {
+ req.Header.Set("Companion_User_Secret", cmn)
}
httpResp, err := cli.mediaHTTP.Do(req)
diff --git a/user.go b/user.go
index ccac26e25..e54fc6157 100644
--- a/user.go
+++ b/user.go
@@ -16,12 +16,12 @@ import (
"google.golang.org/protobuf/proto"
- waBinary "go.mau.fi/whatsmeow/binary"
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/proto/waVnameCert"
- "go.mau.fi/whatsmeow/store"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
+ waBinary "github.com/polymorfa/hypermeow/binary"
+ "github.com/polymorfa/hypermeow/proto/waHistorySync"
+ "github.com/polymorfa/hypermeow/proto/waVnameCert"
+ "github.com/polymorfa/hypermeow/store"
+ "github.com/polymorfa/hypermeow/types"
+ "github.com/polymorfa/hypermeow/types/events"
)
const (
@@ -189,6 +189,7 @@ func (cli *Client) IsOnWhatsApp(ctx context.Context, phones []string) ([]types.I
}
output := make([]types.IsOnWhatsAppResponse, 0, len(jids))
lidEntries := make([]store.LIDMapping, 0, len(jids))
+ usernameEntries := make([]store.ContactUsernameEntry, 0, len(jids))
querySuffix := "@" + types.LegacyUserServer
for _, child := range list.GetChildren() {
ag := child.AttrGetter()
@@ -215,6 +216,10 @@ func (cli *Client) IsOnWhatsApp(ctx context.Context, phones []string) ([]types.I
}
contactNode := child.GetChildByTag("contact")
info.IsIn = contactNode.AttrGetter().String("type") == "in"
+ info.Username = parseUSyncUsername(child)
+ if info.Username != "" && info.JID.Server == types.HiddenUserServer {
+ usernameEntries = append(usernameEntries, store.ContactUsernameEntry{JID: info.JID, Username: info.Username})
+ }
contactQuery, _ := contactNode.Content.([]byte)
info.Query = strings.TrimSuffix(string(contactQuery), querySuffix)
output = append(output, info)
@@ -225,9 +230,44 @@ func (cli *Client) IsOnWhatsApp(ctx context.Context, phones []string) ([]types.I
return output, fmt.Errorf("failed to store LID mappings: %w", err)
}
}
+ cli.storeContactUsernamesBestEffort(ctx, usernameEntries)
return output, nil
}
+func (cli *Client) storeContactUsernamesBestEffort(ctx context.Context, entries []store.ContactUsernameEntry) {
+ if err := putContactUsernames(ctx, cli.Store.Contacts, entries); err != nil {
+ cli.Log.Warnf("Failed to store usernames: %v", err)
+ }
+}
+
+func putContactUsernames(ctx context.Context, contacts store.ContactStore, entries []store.ContactUsernameEntry) error {
+ if len(entries) == 0 {
+ return nil
+ }
+ if batch, ok := contacts.(store.ContactUsernameBatchStore); ok {
+ return batch.PutManyContactUsernames(ctx, entries)
+ }
+ single, ok := contacts.(store.ContactUsernameStore)
+ if !ok {
+ return nil
+ }
+ var firstErr error
+ for _, entry := range entries {
+ if err := single.PutContactUsername(ctx, entry.JID, entry.Username); err != nil && firstErr == nil {
+ firstErr = err
+ }
+ }
+ return firstErr
+}
+
+func parseUSyncUsername(user waBinary.Node) string {
+ if username, ok := user.GetChildByTag("username").Content.([]byte); ok && len(username) > 0 {
+ return string(username)
+ }
+ contact := user.GetChildByTag("contact")
+ return contact.AttrGetter().OptionalString("username")
+}
+
// GetUserInfo gets basic user info (avatar, status, verified business name, device list).
func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types.JID]types.UserInfo, error) {
list, err := cli.usync(ctx, jids, "full", "background", []waBinary.Node{
@@ -236,6 +276,7 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types
{Tag: "picture"},
{Tag: "devices", Attrs: waBinary.Attrs{"version": "2"}},
{Tag: "lid"},
+ {Tag: "username"},
}, UsyncQueryExtras{
IncludePrivacyToken: true,
})
@@ -244,6 +285,7 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types
}
respData := make(map[types.JID]types.UserInfo, len(jids))
mappings := make([]store.LIDMapping, 0, len(jids))
+ usernames := make([]store.ContactUsernameEntry, 0, len(jids))
for _, child := range list.GetChildren() {
jid, jidOK := child.Attrs["jid"].(types.JID)
if child.Tag != "user" || !jidOK {
@@ -261,6 +303,7 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types
lidTag := child.GetChildByTag("lid")
info.LID = lidTag.AttrGetter().OptionalJIDOrEmpty("val")
+ info.Username = parseUSyncUsername(child)
if !info.LID.IsEmpty() {
mappings = append(mappings, store.LIDMapping{PN: jid, LID: info.LID})
@@ -270,6 +313,13 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types
cli.updateBusinessName(ctx, jid, info.LID, nil, verifiedName.Details.GetVerifiedName())
}
respData[jid] = info
+ if info.Username != "" {
+ usernameJID := info.LID
+ if usernameJID.IsEmpty() {
+ usernameJID = jid
+ }
+ usernames = append(usernames, store.ContactUsernameEntry{JID: usernameJID, Username: info.Username})
+ }
}
err = cli.Store.LIDs.PutManyLIDMappings(ctx, mappings)
@@ -277,10 +327,107 @@ func (cli *Client) GetUserInfo(ctx context.Context, jids []types.JID) (map[types
// not worth returning on the error, instead just post a log
cli.Log.Errorf("Failed to place LID mappings from USync call")
}
+ if err := putContactUsernames(ctx, cli.Store.Contacts, usernames); err != nil {
+ cli.Log.Errorf("Failed to store usernames from USync call: %v", err)
+ }
return respData, nil
}
+var ErrUsernameNotFound = errors.New("username not found")
+
+func parseUsernameResolution(list *waBinary.Node) (types.UsernameResolution, error) {
+ children := list.GetChildren()
+ if len(children) != 1 || children[0].Tag != "user" {
+ return types.UsernameResolution{}, ErrUsernameNotFound
+ }
+ user := children[0]
+ contact := user.GetChildByTag("contact")
+ contactAttrs := contact.AttrGetter()
+ if contactAttrs.OptionalString("type") == "out" {
+ return types.UsernameResolution{}, ErrUsernameNotFound
+ }
+ lid := user.AttrGetter().OptionalJIDOrEmpty("jid").ToNonAD()
+ if lid.IsEmpty() {
+ return types.UsernameResolution{KeyRequired: true}, nil
+ }
+ if lid.Server != types.HiddenUserServer {
+ return types.UsernameResolution{}, fmt.Errorf("username resolved to non-LID JID %s", lid)
+ }
+ return types.UsernameResolution{
+ LID: lid,
+ Username: contactAttrs.OptionalString("username"),
+ }, nil
+}
+
+func (cli *Client) ResolveUsername(ctx context.Context, username, key string) (types.UsernameResolution, error) {
+ username = strings.TrimPrefix(strings.TrimSpace(username), "@")
+ if len(username) < 3 || len(username) > 35 {
+ return types.UsernameResolution{}, fmt.Errorf("username must contain 3 to 35 characters")
+ }
+ if key != "" {
+ if len(key) != 4 {
+ return types.UsernameResolution{}, fmt.Errorf("username key must contain 4 digits")
+ }
+ for _, char := range key {
+ if char < '0' || char > '9' {
+ return types.UsernameResolution{}, fmt.Errorf("username key must contain 4 digits")
+ }
+ }
+ }
+ list, err := cli.usync(ctx, []types.JID{types.EmptyJID}, "query", "interactive", []waBinary.Node{
+ {Tag: "contact", Attrs: waBinary.Attrs{"addressing_mode": "lid"}},
+ {Tag: "business", Content: []waBinary.Node{{Tag: "verified_name"}}},
+ }, UsyncQueryExtras{Username: username, UsernameKey: key})
+ if err != nil {
+ return types.UsernameResolution{}, err
+ }
+ result, err := parseUsernameResolution(list)
+ if err != nil || result.KeyRequired {
+ return result, err
+ }
+ if result.Username == "" {
+ result.Username = username
+ }
+ if usernameStore, ok := cli.Store.Contacts.(store.ContactUsernameStore); ok {
+ if err = usernameStore.PutContactUsername(ctx, result.LID, result.Username); err != nil {
+ cli.Log.Warnf("Failed to store username for %s: %v", result.LID, err)
+ }
+ }
+ return result, nil
+}
+
+// ResolveLID returns the stable LID for a phone-number JID.
+func (cli *Client) ResolveLID(ctx context.Context, phone types.JID) (types.JID, error) {
+ device := phone.Device
+ phone = phone.ToNonAD()
+ if phone.Server != types.DefaultUserServer {
+ return types.EmptyJID, fmt.Errorf("cannot resolve non-PN JID %s to LID", phone)
+ }
+ if cli.Store == nil || cli.Store.LIDs == nil {
+ return types.EmptyJID, fmt.Errorf("LID store is unavailable")
+ }
+ lid, err := cli.Store.LIDs.GetLIDForPN(ctx, phone)
+ if err != nil {
+ return types.EmptyJID, fmt.Errorf("get cached LID for %s: %w", phone, err)
+ }
+ if !lid.IsEmpty() {
+ lid = lid.ToNonAD()
+ lid.Device = device
+ return lid, nil
+ }
+ info, err := cli.GetUserInfo(ctx, []types.JID{phone})
+ if err != nil {
+ return types.EmptyJID, fmt.Errorf("resolve LID for %s with USync: %w", phone, err)
+ }
+ lid = info[phone].LID.ToNonAD()
+ if lid.IsEmpty() {
+ return types.EmptyJID, fmt.Errorf("USync returned no LID for %s", phone)
+ }
+ lid.Device = device
+ return lid, nil
+}
+
func (cli *Client) GetBotListV2(ctx context.Context) ([]types.BotListInfo, error) {
resp, err := cli.sendIQ(ctx, infoQuery{
To: types.ServerJID,
@@ -390,6 +537,15 @@ func (cli *Client) parseBusinessProfile(node *waBinary.Node) (*types.BusinessPro
}
address, _ := profileNode.GetChildByTag("address").Content.([]byte)
email, _ := profileNode.GetChildByTag("email").Content.([]byte)
+ description, _ := profileNode.GetChildByTag("description").Content.([]byte)
+ websiteNodes := profileNode.GetChildrenByTag("website")
+ websites := make([]string, 0, len(websiteNodes))
+ for _, websiteNode := range websiteNodes {
+ website, _ := websiteNode.Content.([]byte)
+ websites = append(websites, string(website))
+ }
+ coverPhoto := profileNode.GetChildByTag("cover_photo")
+ coverPhotoID := coverPhoto.AttrGetter().String("id")
businessHour := profileNode.GetChildByTag("business_hours")
businessHourTimezone := businessHour.AttrGetter().String("timezone")
businessHoursConfigs := businessHour.GetChildren()
@@ -433,6 +589,9 @@ func (cli *Client) parseBusinessProfile(node *waBinary.Node) (*types.BusinessPro
JID: jid,
Email: string(email),
Address: string(address),
+ Description: string(description),
+ Websites: websites,
+ CoverPhotoID: coverPhotoID,
Categories: categories,
ProfileOptions: profileOptions,
BusinessHoursTimeZone: businessHourTimezone,
@@ -870,6 +1029,8 @@ func (cli *Client) getFBIDDevices(ctx context.Context, jids []types.JID) ([]type
type UsyncQueryExtras struct {
BotListInfo []types.BotListInfo
IncludePrivacyToken bool
+ Username string
+ UsernameKey string
}
func (cli *Client) usync(ctx context.Context, jids []types.JID, mode, context string, query []waBinary.Node, extra ...UsyncQueryExtras) (*waBinary.Node, error) {
@@ -887,6 +1048,14 @@ func (cli *Client) usync(ctx context.Context, jids []types.JID, mode, context st
for i, jid := range jids {
userList[i].Tag = "user"
jid = jid.ToNonAD()
+ if extras.Username != "" {
+ attrs := waBinary.Attrs{"username": extras.Username}
+ if extras.UsernameKey != "" {
+ attrs["pin"] = extras.UsernameKey
+ }
+ userList[i].Content = []waBinary.Node{{Tag: "contact", Attrs: attrs}}
+ continue
+ }
switch jid.Server {
case types.LegacyUserServer:
diff --git a/username_contact_test.go b/username_contact_test.go
new file mode 100644
index 000000000..388375be2
--- /dev/null
+++ b/username_contact_test.go
@@ -0,0 +1,81 @@
+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
new file mode 100644
index 000000000..09c2153a9
--- /dev/null
+++ b/username_persistence_test.go
@@ -0,0 +1,191 @@
+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
new file mode 100644
index 000000000..1c164c95e
--- /dev/null
+++ b/username_resolution_test.go
@@ -0,0 +1,69 @@
+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)
+ }
+}