Skip to content
Open
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ All notable HyperMeow changes are documented here. HyperMeow uses commit pseudo-

## [Unreleased]

### Added

- Persist hosted (coexistence) pairing as `store.Device.IsHosted` and report it on `events.PairSuccess.IsHosted`. SQL-backed stores upgrade to schema version 20, which adds `whatsmeow_device.is_hosted` (default `false`).
- Add `types.AEAAccountType` (`not_set`, `e2ee`, `hybrid_e2ee`, `non_e2ee`) on `BusinessProfile`, `UserInfo` and `IsOnWhatsAppResponse`, read from `<aea_account_type>` in business profile and usync business results. `GetBusinessProfile` is the reliable source: `GetUserInfo` and `IsOnWhatsApp` do not request the business profile, so their field is set only when the server includes it.
- Add `VerifiedName.Serial`, `Issuer`, `PrivacyMode`, `IsAPI` and `IsBusinessApp`, typed actual-actor and host-storage values, and `MessageInfo.BizPrivacyMode` from `<biz>` on business messages.
- Add `UndecryptableMessage.IsHostedUnavailable` for `<unavailable hosted="true"/>`.

### Fixed

- `GetUserInfo` returns the parsed verified business name in `UserInfo.VerifiedName`.
- Migrate an established hosted PN session when the peer starts sending from its hosted LID. SQL-backed migration uses literal address-prefix ranges so hosted underscores cannot select or delete another account's keys.
- Use one Signal address for a hosted device 99 when decrypting its messages, sending to it and answering its retry receipts: `<lid>:99@hosted.lid` when the account's LID is known, otherwise `<pn>:99@hosted`. Hosted sessions no longer collide with a regular device 99, and sessions stored under the PN hosted address move to the LID hosted address. `MessageInfo.Sender` still reports hosted senders on the regular servers.
- Stop automatic reconnect and emit `CATRefreshError` to generic and connection-scoped observers when a 413/414 failure cannot refresh its token, including when `RefreshCAT` is unset. The missing callback returns `ErrCATRefreshUnavailable` instead of panicking or retrying unchanged credentials.
- Protect the upstream unified-session interval check during concurrent sends.
- Parse newsletter live updates inside `messages` wrappers and retain forward counts, while preserving the legacy unwrapped form.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ decode error.

Custom `store.ChatSettingsStore` implementations must add `PutWASARootSecretID`
and `GetWASARootSecretID` for bot root-secret state. `sqlstore` adds this field
through schema version 19; its existing migrations remain unchanged.
through schema version 19; its existing migrations remain unchanged. Schema
version 20 adds the device `is_hosted` flag for hosted (coexistence) pairing.

## Discord

Expand Down
359 changes: 359 additions & 0 deletions business_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,359 @@
// Copyright (c) 2026 Rajeh Taher
//
// Licensed under the MIT License. See LICENSE-MIT for details.

package whatsmeow

import (
"context"
"slices"
"strings"
"testing"
"time"

"google.golang.org/protobuf/proto"

waBinary "github.com/polymorfa/hypermeow/binary"
"github.com/polymorfa/hypermeow/proto/waVnameCert"
"github.com/polymorfa/hypermeow/store"
"github.com/polymorfa/hypermeow/types"
"github.com/polymorfa/hypermeow/types/events"
waLog "github.com/polymorfa/hypermeow/util/log"
)

func TestParseBusinessProfileAEAAccountType(t *testing.T) {
jid := types.NewJID("15551234567", types.DefaultUserServer)
for _, value := range []types.AEAAccountType{
types.AEAAccountTypeNotSet,
types.AEAAccountTypeE2EE,
types.AEAAccountTypeHybridE2EE,
types.AEAAccountTypeNonE2EE,
} {
// <business_profile><profile jid tag><aea_account_type value/>...</profile></business_profile>
// (WAWebQueryBusinessProfileJob:14-25)
node := waBinary.Node{
Tag: "business_profile",
Content: []waBinary.Node{{
Tag: "profile",
Attrs: waBinary.Attrs{"jid": jid, "tag": "1234567890"},
Content: []waBinary.Node{
{Tag: "aea_account_type", Attrs: waBinary.Attrs{"value": string(value)}},
{Tag: "description", Content: []byte("Synthetic tea shop")},
},
}},
}
profile, err := (&Client{}).parseBusinessProfile(&node)
if err != nil {
t.Fatal(err)
}
if profile.AEAAccountType != value {
t.Fatalf("AEAAccountType = %q, want %q", profile.AEAAccountType, value)
}
if want := value == types.AEAAccountTypeHybridE2EE || value == types.AEAAccountTypeNonE2EE; profile.AEAAccountType.IsAEA() != want {
t.Fatalf("%q IsAEA = %v, want %v", value, !want, want)
}
}
}

func TestParseBusinessProfileWithoutAEAAccountType(t *testing.T) {
node := waBinary.Node{
Tag: "business_profile",
Content: []waBinary.Node{{
Tag: "profile",
Attrs: waBinary.Attrs{"jid": types.NewJID("15551234567", types.DefaultUserServer)},
}},
}
profile, err := (&Client{}).parseBusinessProfile(&node)
if err != nil {
t.Fatal(err)
}
if profile.AEAAccountType != types.AEAAccountTypeUnknown {
t.Fatalf("AEAAccountType = %q, want empty", profile.AEAAccountType)
}
}

func TestParseUSyncAEAAccountType(t *testing.T) {
// <business><verified_name/><profile><aea_account_type value/></profile></business>
// (WAWebUsyncBusiness:10-20)
business := waBinary.Node{
Tag: "business",
Content: []waBinary.Node{
{Tag: "verified_name"},
{Tag: "profile", Content: []waBinary.Node{
{Tag: "aea_account_type", Attrs: waBinary.Attrs{"value": "hybrid_e2ee"}},
}},
},
}
if got := parseUSyncAEAAccountType(business); got != types.AEAAccountTypeHybridE2EE {
t.Fatalf("usync AEA = %q, want hybrid_e2ee", got)
}
if got := parseUSyncAEAAccountType(waBinary.Node{Tag: "business"}); got != types.AEAAccountTypeUnknown {
t.Fatalf("usync AEA without profile = %q, want empty", got)
}
if got := parseUSyncAEAAccountType(waBinary.Node{}); got != types.AEAAccountTypeUnknown {
t.Fatalf("usync AEA without business = %q, want empty", got)
}
}

func buildVerifiedNameCert(t *testing.T, issuer string, serial uint64) []byte {
t.Helper()
details, err := proto.Marshal(&waVnameCert.VerifiedNameCertificate_Details{
Serial: proto.Uint64(serial),
Issuer: proto.String(issuer),
VerifiedName: proto.String("Synthetic Tea"),
})
if err != nil {
t.Fatal(err)
}
cert, err := proto.Marshal(&waVnameCert.VerifiedNameCertificate{Details: details})
if err != nil {
t.Fatal(err)
}
return cert
}

func TestParseVerifiedNameIssuerLevelSerialAndPrivacyMode(t *testing.T) {
// <verified_name verified_level serial actual_actors host_storage privacy_mode_ts>cert</verified_name>
// (WAWebCommonParsersVerifiedName:19-32, WAWebValidateBusinessCertificate:6-24)
node := waBinary.Node{
Tag: "verified_name",
Attrs: waBinary.Attrs{
"verified_level": "high",
"serial": "998877",
"actual_actors": "3",
"host_storage": "2",
"privacy_mode_ts": "1700000000",
"v": "2",
},
Content: buildVerifiedNameCert(t, "ent:wa", 112233),
}
vn, err := parseVerifiedNameContent(node)
if err != nil {
t.Fatal(err)
}
if vn.Issuer != types.VerifiedNameIssuerAPI || !vn.IsAPI() || vn.IsBusinessApp() {
t.Fatalf("issuer = %q, IsAPI=%v IsBusinessApp=%v", vn.Issuer, vn.IsAPI(), vn.IsBusinessApp())
}
if vn.VerifiedLevel != types.VerifiedLevelHigh || vn.Serial != "998877" || vn.Version != 2 {
t.Fatalf("level/serial/version = %q/%q/%d", vn.VerifiedLevel, vn.Serial, vn.Version)
}
want := types.BusinessPrivacyMode{
ActualActors: types.BusinessActualActorsCAPI,
HostStorage: types.BusinessHostStorageFacebook,
Timestamp: time.Unix(1700000000, 0),
}
if vn.PrivacyMode == nil || *vn.PrivacyMode != want {
t.Fatalf("privacy mode = %+v, want %+v", vn.PrivacyMode, want)
}
if vn.ActualActors != 3 || vn.HostStorage != 2 || !vn.PrivacyModeTS.Equal(want.Timestamp) {
t.Fatalf("legacy fields changed: %d/%d/%v", vn.ActualActors, vn.HostStorage, vn.PrivacyModeTS)
}
}

func TestParseVerifiedNameBusinessAppFallsBackToCertificateSerial(t *testing.T) {
node := waBinary.Node{
Tag: "verified_name",
Attrs: waBinary.Attrs{"verified_level": "low"},
Content: buildVerifiedNameCert(t, "smb:wa", 112233),
}
vn, err := parseVerifiedNameContent(node)
if err != nil {
t.Fatal(err)
}
if !vn.IsBusinessApp() || vn.IsAPI() || vn.Serial != "112233" || vn.VerifiedLevel != types.VerifiedLevelLow {
t.Fatalf("unexpected verified name: issuer=%q serial=%q level=%q", vn.Issuer, vn.Serial, vn.VerifiedLevel)
}
if vn.PrivacyMode != nil {
t.Fatalf("privacy mode = %+v, want nil without attributes", vn.PrivacyMode)
}
}

func TestParseBusinessPrivacyModeRequiresKnownCompleteValues(t *testing.T) {
for _, attrs := range []waBinary.Attrs{
{"actual_actors": "2", "host_storage": "2"},
{"actual_actors": "2", "privacy_mode_ts": "1700000000"},
{"actual_actors": "4", "host_storage": "2", "privacy_mode_ts": "1700000000"},
{"actual_actors": "1", "host_storage": "3", "privacy_mode_ts": "1700000000"},
} {
if mode := parseBusinessPrivacyMode(waBinary.Node{Tag: "biz", Attrs: attrs}); mode != nil {
t.Fatalf("attrs %v parsed as %+v, want nil", attrs, mode)
}
}
}

func newBusinessIdentityTestClient() *Client {
own := types.NewJID("15550000000", types.DefaultUserServer)
own.Device = 3
return NewClient(&store.Device{ID: &own, LIDs: &store.NoopStore{}}, waLog.Noop)
}

func TestParseMessageInfoBizPrivacyModeAndVerifiedAttrs(t *testing.T) {
cli := newBusinessIdentityTestClient()
business := types.NewJID("15551234567", types.DefaultUserServer)
// <message verified_level verified_name><verified_name>cert</verified_name><biz .../></message>
// (WAWebHandleMsgParser:309-327)
node := &waBinary.Node{
Tag: "message",
Attrs: waBinary.Attrs{
"from": business,
"id": "3EB0BIZ",
"t": "1700000100",
"type": "text",
"verified_level": "high",
"verified_name": "445566",
},
Content: []waBinary.Node{
{Tag: "verified_name", Content: buildVerifiedNameCert(t, "smb:wa", 112233)},
{Tag: "biz", Attrs: waBinary.Attrs{"actual_actors": "2", "host_storage": "1", "privacy_mode_ts": "1700000000"}},
{Tag: "enc", Attrs: waBinary.Attrs{"type": "msg", "v": "2"}},
},
}
info, err := cli.parseMessageInfo(node)
if err != nil {
t.Fatal(err)
}
want := types.BusinessPrivacyMode{
ActualActors: types.BusinessActualActorsBSP,
HostStorage: types.BusinessHostStorageOnPremise,
Timestamp: time.Unix(1700000000, 0),
}
if info.BizPrivacyMode == nil || *info.BizPrivacyMode != want {
t.Fatalf("biz privacy mode = %+v, want %+v", info.BizPrivacyMode, want)
}
if info.VerifiedName == nil || info.VerifiedName.VerifiedLevel != types.VerifiedLevelHigh || info.VerifiedName.Serial != "445566" || !info.VerifiedName.IsBusinessApp() {
t.Fatalf("verified name = %+v", info.VerifiedName)
}
}

func TestParseMessageInfoIgnoresBotBizPrivacyMode(t *testing.T) {
cli := newBusinessIdentityTestClient()
node := &waBinary.Node{
Tag: "message",
Attrs: waBinary.Attrs{
"from": types.NewJID("867051314767696", types.BotServer),
"id": "3EB0BOT",
"t": "1700000100",
},
Content: []waBinary.Node{
{Tag: "biz", Attrs: waBinary.Attrs{"actual_actors": "2", "host_storage": "1", "privacy_mode_ts": "1700000000"}},
},
}
info, err := cli.parseMessageInfo(node)
if err != nil {
t.Fatal(err)
}
if info.BizPrivacyMode != nil {
t.Fatalf("bot message privacy mode = %+v, want nil", info.BizPrivacyMode)
}
}

func TestUnavailableHostedMessageEvent(t *testing.T) {
for _, tc := range []struct {
name string
attrs waBinary.Attrs
want bool
}{
// <unavailable hosted="true"/> (WAWebHandleMsgParser:245-248)
{"hosted", waBinary.Attrs{"hosted": "true"}, true},
{"view once", waBinary.Attrs{"type": "view_once"}, false},
{"plain", nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
cli, _, sent := newTestRemote(t)
var got []*events.UndecryptableMessage
cli.AddEventHandler(func(evt any) {
if e, ok := evt.(*events.UndecryptableMessage); ok {
got = append(got, e)
}
})
sender := types.NewJID("15551234567", types.DefaultUserServer)
info := &types.MessageInfo{MessageSource: types.MessageSource{Chat: sender, Sender: sender}, ID: "3EB0HOSTED"}
node := &waBinary.Node{
Tag: "message",
Attrs: waBinary.Attrs{"id": "3EB0HOSTED", "from": sender, "t": "1700000000", "type": "text"},
Content: []waBinary.Node{{Tag: "unavailable", Attrs: tc.attrs}},
}
cli.decryptMessages(context.Background(), info, node)
if len(got) != 1 || !got[0].IsUnavailable {
t.Fatalf("UndecryptableMessage events = %+v, want one unavailable event", got)
}
if got[0].IsHostedUnavailable != tc.want {
t.Fatalf("IsHostedUnavailable = %v, want %v", got[0].IsHostedUnavailable, tc.want)
}
if tags := sent.tags(); !slices.ContainsFunc(tags, func(tag string) bool { return strings.HasPrefix(tag, "ack:") }) {
t.Fatalf("sent %v, want the stanza acknowledged", tags)
}
})
}
}

func TestParseDeviceListHostedDevice(t *testing.T) {
// <devices><device-list><device id="0"/><device id="99" is_hosted="true"/></device-list></devices>
// (WAWebUsyncDevice:20, WAWebWid:20,173-174)
devices := waBinary.Node{
Tag: "devices",
Content: []waBinary.Node{{
Tag: "device-list",
Content: []waBinary.Node{
{Tag: "device", Attrs: waBinary.Attrs{"id": "0"}},
{Tag: "device", Attrs: waBinary.Attrs{"id": "99", "is_hosted": "true"}},
},
}},
}
pn := types.NewJID("15551234567", types.DefaultUserServer)
got := parseDeviceList(pn, devices)
want := []types.JID{
{User: pn.User, Server: types.DefaultUserServer},
{User: pn.User, Device: 99, Server: types.HostedServer},
}
if !slices.Equal(got, want) {
t.Fatalf("PN devices = %v, want %v", got, want)
}
lid := types.NewJID("123456789012345", types.HiddenUserServer)
got = parseDeviceList(lid, devices)
if len(got) != 2 || got[1] != (types.JID{User: lid.User, Device: 99, Server: types.HostedLIDServer}) {
t.Fatalf("LID devices = %v", got)
}
}

func TestHostedDeviceJIDRoundTripsThroughBinary(t *testing.T) {
for _, jid := range []types.JID{
{User: "15551234567", Device: 99, Server: types.HostedServer},
{User: "123456789012345", Device: 99, Server: types.HostedLIDServer},
} {
encoded, err := waBinary.Marshal(waBinary.Node{Tag: "to", Attrs: waBinary.Attrs{"jid": jid}})
if err != nil {
t.Fatal(err)
}
decoded, err := waBinary.Unmarshal(encoded[1:])
if err != nil {
t.Fatal(err)
}
if got := decoded.AttrGetter().JID("jid"); got != jid {
t.Fatalf("round trip = %#v, want %#v", got, jid)
}
if got := jid.String(); got != jid.User+":99@"+jid.Server {
t.Fatalf("String() = %q", got)
}
}
}

func TestParseReceiptFromHostedDevice(t *testing.T) {
cli := newBusinessIdentityTestClient()
hosted := types.JID{User: "15551234567", Device: 99, Server: types.HostedServer}
receipt, _, err := cli.parseReceipt(&waBinary.Node{
Tag: "receipt",
Attrs: waBinary.Attrs{"from": hosted, "id": "3EB0SENT", "t": "1700000000", "type": "read"},
})
if err != nil {
t.Fatal(err)
}
// Incoming hosted JIDs are normalized to the account's regular server and keep device 99.
want := types.JID{User: hosted.User, Device: 99, Server: types.DefaultUserServer}
if receipt.Sender != want || receipt.Chat != want.ToNonAD() {
t.Fatalf("receipt source = %v in %v, want %v in %v", receipt.Sender, receipt.Chat, want, want.ToNonAD())
}
if len(receipt.MessageIDs) != 1 || receipt.MessageIDs[0] != "3EB0SENT" || receipt.Type != types.ReceiptTypeRead {
t.Fatalf("unexpected receipt: %+v", receipt)
}
}
Loading
Loading