From 3950d6db75627ab51bd7c485353db6d8665fb0fd Mon Sep 17 00:00:00 2001 From: Chris Butler Date: Thu, 15 Oct 2026 12:22:57 -0400 Subject: [PATCH] Refresh BlueBubbles contacts and reconcile direct and group profiles --- contactsync.go | 26 ++++++ contactsync_test.go | 48 +++++++++++ groupcontactsync.go | 99 +++++++++++++++++++++++ groupcontactsync_test.go | 111 ++++++++++++++++++++++++++ imessage/bluebubbles/api.go | 27 ++++++- imessage/bluebubbles/contacts_test.go | 89 +++++++++++++++++++++ main.go | 11 ++- puppet.go | 29 ++++++- 8 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 contactsync.go create mode 100644 contactsync_test.go create mode 100644 groupcontactsync.go create mode 100644 groupcontactsync_test.go create mode 100644 imessage/bluebubbles/contacts_test.go diff --git a/contactsync.go b/contactsync.go new file mode 100644 index 00000000..f47be2d6 --- /dev/null +++ b/contactsync.go @@ -0,0 +1,26 @@ +package main + +// Refresh existing contact profiles before slower per-room chat sync. This also +// updates group participants whose old native chat is no longer available. +// Contact records never create new rooms or ghost identities here. +func (br *IMBridge) syncBlueBubblesContactProfiles() { + if br.Config.IMessage.Platform != "bluebubbles" { + return + } + matched := 0 + var contacts []*Puppet + for _, puppet := range br.GetAllPuppets() { + if puppet == nil { + continue + } + contact, err := br.IM.GetContactInfo(puppet.ID) + if err != nil || contact == nil { + continue + } + puppet.SyncWithContact(contact) + contacts = append(contacts, puppet) + matched++ + } + br.ZLog.Info().Int("matched_profiles", matched).Msg("BlueBubbles contact profile sync complete") + br.syncBlueBubblesGroupProfiles(contacts) +} diff --git a/contactsync_test.go b/contactsync_test.go new file mode 100644 index 00000000..feb1b7cf --- /dev/null +++ b/contactsync_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "path/filepath" + "testing" + + "go.mau.fi/mautrix-imessage/config" + "go.mau.fi/mautrix-imessage/database" + "go.mau.fi/mautrix-imessage/imessage" + "go.mau.fi/util/dbutil" + log "maunium.net/go/maulogger/v2" +) + +func TestContactMetadataIncludesExistingUnifiedRoomsWithoutCreatingPortals(t *testing.T) { + raw, err := dbutil.NewWithDialect(filepath.Join(t.TempDir(), "bridge.db"), "sqlite3") + if err != nil { + t.Fatal(err) + } + defer raw.RawDB.Close() + db := database.New(raw, log.Create()) + if err := db.Upgrade(); err != nil { + t.Fatal(err) + } + br := &IMBridge{DB: db, Config: &config.Config{}, portalsByGUID: make(map[string]*Portal)} + const address = "+12025550123" + for _, service := range []string{"iMessage", "any"} { + guid := imessage.Identifier{Service: service, LocalID: address}.String() + br.portalsByGUID[guid] = &Portal{Portal: &database.Portal{GUID: guid}} + } + puppet := &Puppet{Puppet: &database.Puppet{ID: address}, bridge: br} + visited := make(map[string]int) + puppet.updatePortalMeta(func(p *Portal) { visited[p.GUID]++ }) + if len(visited) != 2 { + t.Fatalf("updated %d rooms, wanted both legacy and unified rooms", len(visited)) + } + for _, count := range visited { + if count != 1 { + t.Fatal("duplicate metadata update") + } + } + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM portal").Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("contact update created an unused portal") + } +} diff --git a/groupcontactsync.go b/groupcontactsync.go new file mode 100644 index 00000000..a8882aae --- /dev/null +++ b/groupcontactsync.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Beeper can retain a room-specific profile after the global ghost profile is +// updated. Reconcile existing group members independently of native chat sync: +// even an old group that BlueBubbles no longer returns needs current contacts. +func (br *IMBridge) syncBlueBubblesGroupProfiles(contacts []*Puppet) { + if br.Config.IMessage.Platform != "bluebubbles" { + return + } + puppets := make(map[id.UserID]*Puppet) + for _, puppet := range contacts { + if puppet != nil { + puppets[puppet.MXID] = puppet + } + } + rooms, checked, updated, failed := 0, 0, 0, 0 + seen := make(map[id.RoomID]bool) + for _, portal := range br.GetAllPortals() { + if portal == nil || !portal.Identifier.IsGroup || portal.MXID == "" || seen[portal.MXID] { + continue + } + seen[portal.MXID] = true + // Use Client directly: inspecting an inaccessible/left room must never + // cause EnsureJoined to invite or rejoin anyone. + members, err := br.Bot.Client.JoinedMembers(portal.MXID) + if err != nil { + failed++ + continue + } + rooms++ + for userID, member := range members.Joined { + puppet := puppets[userID] + if puppet == nil { + continue + } + checked++ + name, avatar := puppet.Displayname, puppet.AvatarURL.String() + if !groupProfileDiffers(member.DisplayName, member.AvatarURL, name, avatar) { + continue + } + changed, err := reconcileGroupMemberProfile(puppet.Intent.Client, portal.MXID, userID, name, avatar) + if err != nil { + failed++ + } else if changed { + updated++ + } + } + } + br.ZLog.Info().Int("rooms", rooms).Int("checked_profiles", checked). + Int("updated_profiles", updated).Int("failed_operations", failed). + Msg("BlueBubbles group contact profile sync complete") +} + +func groupProfileDiffers(currentName, currentAvatar, name, avatar string) bool { + return (name != "" && name != currentName) || (avatar != "" && avatar != currentAvatar) +} + +func reconcileGroupMemberProfile(client *mautrix.Client, room id.RoomID, user id.UserID, name, avatar string) (bool, error) { + var content map[string]any + if err := client.StateEvent(room, event.StateMember, user.String(), &content); err != nil { + return false, err + } + // Fetch fresh state before writing; never resurrect a departed participant. + if content["membership"] != "join" { + return false, nil + } + currentName, _ := content["displayname"].(string) + currentAvatar, _ := content["avatar_url"].(string) + if !groupProfileDiffers(currentName, currentAvatar, name, avatar) { + return false, nil + } + // Preserve unknown extension fields and existing photos when the source has + // no image. Only the existing ghost's own joined profile is updated. + if name != "" { + content["displayname"] = name + } + if avatar != "" { + content["avatar_url"] = avatar + } + if _, err := client.SendStateEvent(room, event.StateMember, user.String(), content); err != nil { + return false, err + } + var actual map[string]any + if err := client.StateEvent(room, event.StateMember, user.String(), &actual); err != nil { + return false, err + } + if actual["membership"] != "join" || actual["displayname"] != content["displayname"] || actual["avatar_url"] != content["avatar_url"] { + return false, fmt.Errorf("group contact profile readback did not match") + } + return true, nil +} diff --git a/groupcontactsync_test.go b/groupcontactsync_test.go new file mode 100644 index 00000000..55c07877 --- /dev/null +++ b/groupcontactsync_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +func TestGroupContactReconciliation(t *testing.T) { + for _, tc := range []struct { + name, membership, currentName, currentAvatar, wantedName, wantedAvatar string + changed bool + }{ + {"stale name with unchanged photo", "join", "+12025550123", "mxc://test/photo", "Contact Name", "mxc://test/photo", true}, + {"stale photo", "join", "Contact Name", "mxc://test/old", "Contact Name", "mxc://test/new", true}, + {"missing source photo retains room photo", "join", "+12025550123", "mxc://test/photo", "Contact Name", "", true}, + {"already current", "join", "Contact Name", "mxc://test/photo", "Contact Name", "mxc://test/photo", false}, + {"left member stays left", "leave", "old", "", "Contact Name", "mxc://test/photo", false}, + {"invited member stays invited", "invite", "old", "", "Contact Name", "mxc://test/photo", false}, + } { + t.Run(tc.name, func(t *testing.T) { + content := map[string]any{"membership": tc.membership, "displayname": tc.currentName, "avatar_url": tc.currentAvatar, "custom.extension": map[string]any{"keep": true}} + writes := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/_matrix/client/v3/rooms/!group:test/state/m.room.member/@ghost:test" { + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(404) + return + } + if r.Method == "PUT" { + writes++ + json.NewDecoder(r.Body).Decode(&content) + json.NewEncoder(w).Encode(map[string]string{"event_id": "$updated"}) + } else { + json.NewEncoder(w).Encode(content) + } + })) + defer server.Close() + client, err := mautrix.NewClient(server.URL, id.UserID("@ghost:test"), "test") + if err != nil { + t.Fatal(err) + } + changed, err := reconcileGroupMemberProfile(client, "!group:test", "@ghost:test", tc.wantedName, tc.wantedAvatar) + if err != nil || changed != tc.changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + if content["membership"] != tc.membership || !reflect.DeepEqual(content["custom.extension"], map[string]any{"keep": true}) { + t.Fatal("changed membership or discarded extension") + } + if tc.changed { + if writes != 1 || content["displayname"] != tc.wantedName { + t.Fatal("name not reconciled") + } + if tc.wantedAvatar == "" && content["avatar_url"] != tc.currentAvatar { + t.Fatal("removed existing photo") + } + } else if writes != 0 { + t.Fatal("unnecessary write") + } + changed, err = reconcileGroupMemberProfile(client, "!group:test", "@ghost:test", tc.wantedName, tc.wantedAvatar) + if err != nil || changed { + t.Fatal("repeated sync was not idempotent") + } + }) + } +} + +func TestGroupContactWriteFailureIsRetried(t *testing.T) { + writes := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" { + writes++ + w.WriteHeader(403) + json.NewEncoder(w).Encode(map[string]string{"errcode": "M_FORBIDDEN", "error": "test"}) + return + } + json.NewEncoder(w).Encode(map[string]string{"membership": "join", "displayname": "old"}) + })) + defer server.Close() + client, _ := mautrix.NewClient(server.URL, "@ghost:test", "test") + for i := 0; i < 2; i++ { + changed, err := reconcileGroupMemberProfile(client, "!group:test", "@ghost:test", "Contact Name", "") + if err == nil || changed { + t.Fatal("failed write reported as success") + } + } + if writes != 2 { + t.Fatal("failed update not retried") + } +} + +func TestGroupContactRequiresMatchingReadback(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" { + json.NewEncoder(w).Encode(map[string]string{"event_id": "$accepted"}) + return + } + json.NewEncoder(w).Encode(map[string]string{"membership": "join", "displayname": "old"}) + })) + defer server.Close() + client, _ := mautrix.NewClient(server.URL, "@ghost:test", "test") + changed, err := reconcileGroupMemberProfile(client, "!group:test", "@ghost:test", "Contact Name", "") + if err == nil || changed { + t.Fatal("an accepted write with stale readback was reported as verified") + } +} diff --git a/imessage/bluebubbles/api.go b/imessage/bluebubbles/api.go index 3d3d86fa..e43f49bb 100644 --- a/imessage/bluebubbles/api.go +++ b/imessage/bluebubbles/api.go @@ -57,6 +57,8 @@ type blueBubbles struct { contactsLastRefresh time.Time contacts []Contact + contactsLock sync.RWMutex + contactsRefreshLock sync.Mutex bbRequestLock sync.Mutex @@ -86,7 +88,9 @@ func (bb *blueBubbles) Start(readyCallback func()) error { // Preload some caches bb.usingPrivateAPI = bb.isPrivateAPI() - bb.RefreshContactList() + if err := bb.RefreshContactList(); err != nil { + bb.log.Warn().Err(err).Msg("Failed to refresh contact cache at startup") + } if err := bb.connectAndListen(); err != nil { return err @@ -861,6 +865,8 @@ func (bb *blueBubbles) GetChatsWithMessagesAfter(minDate time.Time) (resp []imes } func (bb *blueBubbles) matchHandleToContact(address string) *Contact { + bb.contactsLock.RLock() + defer bb.contactsLock.RUnlock() var matchedContact *Contact numericAddress := numericOnly(address) @@ -911,6 +917,8 @@ func (bb *blueBubbles) matchHandleToContact(address string) *Contact { } func (bb *blueBubbles) SearchContactList(input string) ([]*imessage.Contact, error) { + bb.contactsLock.RLock() + defer bb.contactsLock.RUnlock() bb.log.Trace().Str("input", input).Msg("SearchContactList") var matchedContacts []*imessage.Contact @@ -950,7 +958,7 @@ func (bb *blueBubbles) GetContactInfo(identifier string) (resp *imessage.Contact contact := bb.matchHandleToContact(identifier) - bb.log.Trace().Str("identifier", identifier).Interface("contact", contact).Msg("GetContactInfo: found a contact") + bb.log.Trace().Bool("matched", contact != nil).Msg("GetContactInfo: contact lookup complete") if contact != nil { resp, _ = bb.convertBBContactToiMessageContact(contact) @@ -963,6 +971,8 @@ func (bb *blueBubbles) GetContactInfo(identifier string) (resp *imessage.Contact } func (bb *blueBubbles) GetContactList() (resp []*imessage.Contact, err error) { + bb.contactsLock.RLock() + defer bb.contactsLock.RUnlock() bb.log.Trace().Msg("GetContactList") for _, contact := range bb.contacts { @@ -974,6 +984,8 @@ func (bb *blueBubbles) GetContactList() (resp []*imessage.Contact, err error) { } func (bb *blueBubbles) RefreshContactList() error { + bb.contactsRefreshLock.Lock() + defer bb.contactsRefreshLock.Unlock() bb.log.Trace().Msg("refreshContactsList") var contactResponse ContactResponse @@ -985,9 +997,16 @@ func (bb *blueBubbles) RefreshContactList() error { return err } - // save contacts for later + bb.contactsLock.Lock() + defer bb.contactsLock.Unlock() + // Permission loss can look like a successful empty response. Keep the last + // useful cache instead of degrading profiles during a temporary outage. + if len(contactResponse.Data) == 0 && len(bb.contacts) > 0 { + return errors.New("BlueBubbles returned an empty contact list; retaining cached contacts") + } bb.contacts = contactResponse.Data bb.contactsLastRefresh = time.Now() + bb.log.Info().Int("contacts", len(bb.contacts)).Msg("Refreshed BlueBubbles contact cache") return nil } @@ -1602,7 +1621,7 @@ func (bb *blueBubbles) convertBBContactToiMessageContact(bbContact *Contact) (*i convertedID = "" } - if *bbContact.Avatar != "" { + if bbContact.Avatar != nil && *bbContact.Avatar != "" { imageData, err = base64.StdEncoding.DecodeString(*bbContact.Avatar) if err != nil { bb.log.Error().Err(err).Str("DisplayName", bbContact.DisplayName).Msg("Error decoding contact avatar") diff --git a/imessage/bluebubbles/contacts_test.go b/imessage/bluebubbles/contacts_test.go new file mode 100644 index 00000000..0c0703ee --- /dev/null +++ b/imessage/bluebubbles/contacts_test.go @@ -0,0 +1,89 @@ +package bluebubbles + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + + "github.com/rs/zerolog" + "go.mau.fi/mautrix-imessage/imessage" +) + +type contactTestBridge struct { + imessage.Bridge + cfg imessage.PlatformConfig +} + +func (b *contactTestBridge) GetConnectorConfig() *imessage.PlatformConfig { return &b.cfg } + +func TestContactRefreshUpdatesProfilesAndRetainsCacheOnFailure(t *testing.T) { + var generation atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/contact" || r.URL.Query().Get("extraProperties") != "avatar" { + t.Error("contact refresh must request contact photos") + } + if generation.Load() == 3 { + _, _ = w.Write([]byte("invalid response")) + return + } + contacts := []Contact{} + if generation.Load() < 2 { + c := Contact{ID: "contact-id", FirstName: "Before", Emails: []Email{{Address: "test@example.org"}}} + if generation.Load() == 1 { + photo := "cGhvdG8=" + c.FirstName, c.Avatar = "After", &photo + } + contacts = append(contacts, c) + } + _ = json.NewEncoder(w).Encode(ContactResponse{Status: 200, Data: contacts}) + })) + defer server.Close() + bb := &blueBubbles{bridge: &contactTestBridge{cfg: imessage.PlatformConfig{BlueBubblesURL: server.URL}}, log: zerolog.Nop()} + if err := bb.RefreshContactList(); err != nil { + t.Fatal(err) + } + contact, err := bb.GetContactInfo("test@example.org") + if err != nil || contact.Name() != "Before" || contact.Avatar != nil { + t.Fatal("contact without a photo must still load") + } + generation.Store(1) + if err := bb.RefreshContactList(); err != nil { + t.Fatal(err) + } + contact, err = bb.GetContactInfo("test@example.org") + if err != nil || contact.Name() != "After" || string(contact.Avatar) != "photo" { + t.Fatal("refresh did not update the name and photo") + } + for _, failure := range []int32{2, 3} { + generation.Store(failure) + if err := bb.RefreshContactList(); err == nil { + t.Fatal("empty or invalid response must report failure with an existing cache") + } + contact, err = bb.GetContactInfo("test@example.org") + if err != nil || contact.Name() != "After" || string(contact.Avatar) != "photo" { + t.Fatal("temporary contact failure discarded the known profile") + } + } + generation.Store(1) + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 20; j++ { + if err := bb.RefreshContactList(); err != nil { + t.Error(err) + } + if _, err := bb.GetContactInfo("test@example.org"); err != nil { + t.Error(err) + } + _, _ = bb.GetContactList() + _, _ = bb.SearchContactList("After") + } + }() + } + wg.Wait() +} diff --git a/main.go b/main.go index 9a0a99ca..b962b753 100644 --- a/main.go +++ b/main.go @@ -543,7 +543,10 @@ func (br *IMBridge) Start() { br.Log.Debugln("Starting IPC loop") go br.IPC.Loop() - go br.StartupSync() + go func() { + br.syncBlueBubblesContactProfiles() + br.StartupSync() + }() br.ZLog.Info().Msg("Initialization complete") go br.PeriodicSync() } @@ -618,6 +621,12 @@ func (br *IMBridge) PeriodicSync() { for { time.Sleep(time.Hour) br.Log.Infoln("Executing periodic chat/contact info sync") + if br.Config.IMessage.Platform == "bluebubbles" { + if err := br.IM.RefreshContactList(); err != nil { + br.Log.Warnln("Failed to refresh BlueBubbles contacts; using cached profiles:", err) + } + br.syncBlueBubblesContactProfiles() + } for _, portal := range br.GetAllPortals() { if len(portal.MXID) > 0 { portal.log.Infoln("Syncing portal (periodic sync, existing portal)") diff --git a/puppet.go b/puppet.go index 77c05e03..2a3f3fda 100644 --- a/puppet.go +++ b/puppet.go @@ -250,18 +250,29 @@ func applyMeta(portal *Portal, meta func(portal *Portal)) { } func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) { - imID := imessage.Identifier{Service: "iMessage", LocalID: puppet.ID}.String() - applyMeta(puppet.bridge.GetPortalByGUID(imID), meta) - smsID := imessage.Identifier{Service: "SMS", LocalID: puppet.ID}.String() - applyMeta(puppet.bridge.GetPortalByGUID(smsID), meta) + // Unified macOS chats also need profile updates. Only touch existing portals; + // a contact change must not create an unused portal for another transport. + seen := make(map[*Portal]bool) + for _, service := range []string{"iMessage", "SMS", "any"} { + guid := imessage.Identifier{Service: service, LocalID: puppet.ID}.String() + portal := puppet.bridge.GetPortalByGUIDIfExists(guid) + if portal != nil && !seen[portal] { + seen[portal] = true + applyMeta(portal, meta) + } + } } func (puppet *Puppet) updatePortalAvatar() { puppet.updatePortalMeta(func(portal *Portal) { + if portal.AvatarURL == puppet.AvatarURL || puppet.AvatarURL.IsEmpty() { + return + } if len(portal.MXID) > 0 && portal.shouldSetDMRoomMetadata() { _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, puppet.AvatarURL) if err != nil { portal.log.Warnln("Failed to set avatar:", err) + return } } portal.AvatarURL = puppet.AvatarURL @@ -273,10 +284,14 @@ func (puppet *Puppet) updatePortalAvatar() { func (puppet *Puppet) updatePortalName() { puppet.updatePortalMeta(func(portal *Portal) { + if portal.Name == puppet.Displayname || puppet.Displayname == "" { + return + } if len(portal.MXID) > 0 && portal.shouldSetDMRoomMetadata() { _, err := portal.MainIntent().SetRoomName(portal.MXID, puppet.Displayname) if err != nil { portal.log.Warnln("Failed to set name:", err) + return } } portal.Name = puppet.Displayname @@ -380,4 +395,10 @@ func (puppet *Puppet) SyncWithContact(contact *imessage.Contact) { if update { puppet.Update() } + // An unchanged ghost profile can still belong to a newer unified room whose + // metadata was never populated. Reconcile existing rooms, skipping matches. + if contact != nil { + go puppet.updatePortalName() + go puppet.updatePortalAvatar() + } }