Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions contactsync.go
Original file line number Diff line number Diff line change
@@ -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)
}
48 changes: 48 additions & 0 deletions contactsync_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
99 changes: 99 additions & 0 deletions groupcontactsync.go
Original file line number Diff line number Diff line change
@@ -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
}
111 changes: 111 additions & 0 deletions groupcontactsync_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
27 changes: 23 additions & 4 deletions imessage/bluebubbles/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ type blueBubbles struct {

contactsLastRefresh time.Time
contacts []Contact
contactsLock sync.RWMutex
contactsRefreshLock sync.Mutex

bbRequestLock sync.Mutex

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down
Loading