Repository: mautrix/signal (language: python)

Issue:
Don't "downgrade" displaynames
The go bridge seems to be suffering from the same problem the python bridge suffered from: renaming of users on every sync. For now I can only see users being renamed back and forth between phone number and profile name, so a good first measure might be to not downgrade a name, i.e. not rename to a phone number if the current one is a profile name.
I can't yet tell whether some profile name information will eventually lose sync between different bridge users, I guess I'll wait and file another bug report in that case.
Use latest profile info
This is an alternative approach to #445 for helping with #396: if a Signal profile cannot be fetched by a particular bridged user's Signal account, use the profile info from the most recent successful fetch done by some other bridge user.

Profiles retrieved this way are used only as a fallback for setting a Signal puppet's displayname/avatar, not for updating the contact store.


Discussion / hints:
I think this was solved with "name quality" in the python bridge, but I think a more straightforward way would be to just store the profile name and check if it's empty - in that case, just use the previously known profile name.
Name quality is probably required, possibly even more complicated than the python bridge. It should include the profile fetch timestamp or some other version identifier for the profile name.
This appears to affect avatars, too. While re-setting the same avatar seems fixed, I'm still seeing avatars being removed and re-set.

Is it possible that contact info is sometimes empty and not updating empty contact info would already solve the problem? Maybe checking for empty profileKey or something would work?
> Is it possible that contact info is sometimes empty and not updating empty contact info would already solve the problem? Maybe checking for empty profileKey or something would work?

What can happen is that the contact info isn't _entirely_ empty, but fields that had been filled on an earlier fetch are empty on a later fetch.  For example, a fetched info can have all but its phone number be empty, even if an earlier fetch included a profile name.
I figured out the issue for profile names in my particular case I think. It seems the bridge attempts to sync profiles regardless of whether a profile key is available or not (`profile_key == \x` in the database). If a bridge user has someone in their contact list but hasn't spoken to them, their profile will return empty. If someone else has their profile key and can therefore get their profile name, the bridge will toggle between name and phone number.

It probably doesn't make sense to attempt fetching profiles if no profile key is available. Disabling that should significantly improve the situation.

Edit: Nevermind, you did that already
Querying the database for profiles fetched by other users seems weird 🤔

The mechanism used in other bridges and the old signal bridge is the name quality field: if fetching doesn't return profile info (or it fails) and the puppet already has a profile name, then just don't try to update the name at all.
I used profile recency as a simpler alternative to a proper quality metric, which I wasn't sure how to implement properly in light of [it being more complicated in this version of the bridge](https://github.com/mautrix/signal/issues/396#issuecomment-1870046745).

The idea here is that someone else having a more recent fetch for a given user's profile should be analogous to higher-quality profile data for that user.

This is admittedly an incomplete solution to the name quality problem, so feel free to close this if you'd rather go straight to a full solution.
It might not necessarily need to be more complicated since the profile info is refetched each time, at least assuming a successful fetch means it's up to date. I'll see if I can come up with a simple solution

Fix patch:
diff --git a/config/bridge.go b/config/bridge.go
index 5adc6351..088d2869 100644
--- a/config/bridge.go
+++ b/config/bridge.go
@@ -35,6 +35,7 @@ type BridgeConfig struct {
 	DisplaynameTemplate   string `yaml:"displayname_template"`
 	PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
 	UseContactAvatars     bool   `yaml:"use_contact_avatars"`
+	UseOutdatedProfiles   bool   `yaml:"use_outdated_profiles"`
 	NumberInTopic         bool   `yaml:"number_in_topic"`
 
 	NoteToSelfAvatar id.ContentURIString `yaml:"note_to_self_avatar"`
@@ -169,12 +170,12 @@ type DisplaynameParams struct {
 func (bc BridgeConfig) FormatDisplayname(contact *types.Contact) string {
 	var buffer strings.Builder
 	_ = bc.displaynameTemplate.Execute(&buffer, DisplaynameParams{
-		ProfileName: contact.ProfileName,
+		ProfileName: contact.Profile.Name,
 		ContactName: contact.ContactName,
 		//Username:    contact.Username,
 		PhoneNumber: contact.E164,
 		UUID:        contact.UUID.String(),
-		AboutEmoji:  contact.ProfileAboutEmoji,
+		AboutEmoji:  contact.Profile.AboutEmoji,
 	})
 	return buffer.String()
 }
diff --git a/config/upgrade.go b/config/upgrade.go
index eb924d2c..cb0ab06c 100644
--- a/config/upgrade.go
+++ b/config/upgrade.go
@@ -83,6 +83,7 @@ func DoUpgrade(helper *up.Helper) {
 	}
 	helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
 	helper.Copy(up.Bool, "bridge", "use_contact_avatars")
+	helper.Copy(up.Bool, "bridge", "use_outdated_profiles")
 	helper.Copy(up.Bool, "bridge", "number_in_topic")
 	helper.Copy(up.Str, "bridge", "note_to_self_avatar")
 	helper.Copy(up.Int, "bridge", "portal_message_buffer")
diff --git a/database/puppet.go b/database/puppet.go
index 8c300dfa..99a65ffe 100644
--- a/database/puppet.go
+++ b/database/puppet.go
@@ -19,6 +19,7 @@ package database
 import (
 	"context"
 	"database/sql"
+	"time"
 
 	"github.com/google/uuid"
 	"go.mau.fi/util/dbutil"
@@ -28,7 +29,7 @@ import (
 const (
 	puppetBaseSelect = `
         SELECT uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url, name_set, avatar_set,
-               contact_info_set, is_registered, custom_mxid, access_token
+               contact_info_set, is_registered, profile_fetched_at, custom_mxid, access_token
         FROM puppet
 	`
 	getPuppetBySignalIDQuery   = puppetBaseSelect + `WHERE uuid=$1`
@@ -38,18 +39,18 @@ const (
 	updatePuppetQuery          = `
 		UPDATE puppet SET
 			number=$2, name=$3, name_quality=$4, avatar_path=$5, avatar_hash=$6, avatar_url=$7,
-			name_set=$8, avatar_set=$9, contact_info_set=$10, is_registered=$11,
-			custom_mxid=$12, access_token=$13
+			name_set=$8, avatar_set=$9, contact_info_set=$10, is_registered=$11, profile_fetched_at=$12,
+			custom_mxid=$13, access_token=$14
 		WHERE uuid=$1
 	`
 	insertPuppetQuery = `
 		INSERT INTO puppet (
 			uuid, number, name, name_quality, avatar_path, avatar_hash, avatar_url,
-			name_set, avatar_set, contact_info_set, is_registered,
+			name_set, avatar_set, contact_info_set, is_registered, profile_fetched_at,
 			custom_mxid, access_token
 		)
 		VALUES (
-			$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
+			$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
 		)
 	`
 )
@@ -71,11 +72,12 @@ type Puppet struct {
 	NameSet     bool
 	AvatarSet   bool
 
-	IsRegistered bool
+	IsRegistered     bool
+	ContactInfoSet   bool
+	ProfileFetchedAt time.Time
 
-	CustomMXID     id.UserID
-	AccessToken    string
-	ContactInfoSet bool
+	CustomMXID  id.UserID
+	AccessToken string
 }
 
 func newPuppet(qh *dbutil.QueryHelper[*Puppet]) *Puppet {
@@ -100,6 +102,7 @@ func (pq *PuppetQuery) GetAllWithCustomMXID(ctx context.Context) ([]*Puppet, err
 
 func (p *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) {
 	var number, customMXID sql.NullString
+	var profileFetchedAt sql.NullInt64
 	err := row.Scan(
 		&p.SignalID,
 		&number,
@@ -112,14 +115,18 @@ func (p *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) {
 		&p.AvatarSet,
 		&p.ContactInfoSet,
 		&p.IsRegistered,
+		&profileFetchedAt,
 		&customMXID,
 		&p.AccessToken,
 	)
 	if err != nil {
-		return nil, nil
+		return nil, err
 	}
 	p.Number = number.String
 	p.CustomMXID = id.UserID(customMXID.String)
+	if profileFetchedAt.Valid {
+		p.ProfileFetchedAt = time.UnixMilli(profileFetchedAt.Int64)
+	}
 	return p, nil
 }
 
@@ -136,6 +143,7 @@ func (p *Puppet) sqlVariables() []any {
 		p.AvatarSet,
 		p.ContactInfoSet,
 		p.IsRegistered,
+		dbutil.UnixMilliPtr(p.ProfileFetchedAt),
 		dbutil.StrPtr(p.CustomMXID),
 		p.AccessToken,
 	}
diff --git a/database/upgrades/20-puppet-profile-fetch-ts.sql b/database/upgrades/20-puppet-profile-fetch-ts.sql
new file mode 100644
index 00000000..b398b2f6
--- /dev/null
+++ b/database/upgrades/20-puppet-profile-fetch-ts.sql
@@ -0,0 +1,2 @@
+-- v20 (compatible with v17+): Add profile fetch timestamp for puppets
+ALTER TABLE puppet ADD profile_fetched_at BIGINT;
diff --git a/example-config.yaml b/example-config.yaml
index 086d6a52..a509a0c4 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -106,6 +106,8 @@ bridge:
     private_chat_portal_meta: default
     # Should avatars from the user's contact list be used? This is not safe on multi-user instances.
     use_contact_avatars: false
+    # Should the bridge sync ghost user info even if profile fetching fails? This is not safe on multi-user instances.
+    use_outdated_profiles: false
     # Should the Signal user's phone number be included in the room topic in private chat portal rooms?
     number_in_topic: true
     # Avatar image for the Note to Self room.
diff --git a/go.mod b/go.mod
index 59bd60f5..8e3abaa8 100644
--- a/go.mod
+++ b/go.mod
@@ -15,12 +15,12 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/stretchr/testify v1.8.4
 	github.com/tidwall/gjson v1.17.1
-	go.mau.fi/util v0.4.0
+	go.mau.fi/util v0.4.1-0.20240222202553-953608f657a3
 	golang.org/x/crypto v0.19.0
 	golang.org/x/exp v0.0.0-20240213143201-ec583247a57a
 	golang.org/x/net v0.21.0
 	google.golang.org/protobuf v1.32.0
-	maunium.net/go/mautrix v0.18.0-beta.1
+	maunium.net/go/mautrix v0.18.0-beta.1.0.20240223191208-581aa8015501
 	nhooyr.io/websocket v1.8.10
 )
 
diff --git a/go.sum b/go.sum
index 8ff1bae5..db6ca004 100644
--- a/go.sum
+++ b/go.sum
@@ -71,8 +71,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
 github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
 github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
 github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
-go.mau.fi/util v0.4.0 h1:S2X3qU4pUcb/vxBRfAuZjbrR9xVMAXSjQojNBLPBbhs=
-go.mau.fi/util v0.4.0/go.mod h1:leeiHtgVBuN+W9aDii3deAXnfC563iN3WK6BF8/AjNw=
+go.mau.fi/util v0.4.1-0.20240222202553-953608f657a3 h1:NcRrdzORHKab5bP1Z8BpH0nxsxsvH0iPPZLpOUN+UIc=
+go.mau.fi/util v0.4.1-0.20240222202553-953608f657a3/go.mod h1:leeiHtgVBuN+W9aDii3deAXnfC563iN3WK6BF8/AjNw=
 go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
 go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
 golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
@@ -99,7 +99,7 @@ maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
 maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
 maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
 maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
-maunium.net/go/mautrix v0.18.0-beta.1 h1:YAr4PxmcrJzUHR56p/MLDQ0qS7PsvaqXERAtC4aSkYA=
-maunium.net/go/mautrix v0.18.0-beta.1/go.mod h1:1Q8P5C/uNmSBmull6DSqcawpg/E7hcGLQCD+JoU+vUo=
+maunium.net/go/mautrix v0.18.0-beta.1.0.20240223191208-581aa8015501 h1:3STixn49dd7VXL+p4hW0AEWy5/BeZlgA3i3BVsIgtqM=
+maunium.net/go/mautrix v0.18.0-beta.1.0.20240223191208-581aa8015501/go.mod h1:1Q8P5C/uNmSBmull6DSqcawpg/E7hcGLQCD+JoU+vUo=
 nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
 nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
diff --git a/pkg/libsignalgo/profilekey.go b/pkg/libsignalgo/profilekey.go
index 2cd10005..ba43a60a 100644
--- a/pkg/libsignalgo/profilekey.go
+++ b/pkg/libsignalgo/profilekey.go
@@ -35,19 +35,25 @@ type ProfileKeyCommitment [C.SignalPROFILE_KEY_COMMITMENT_LEN]byte
 type ProfileKeyVersion [C.SignalPROFILE_KEY_VERSION_ENCODED_LEN]byte
 type AccessKey [C.SignalACCESS_KEY_LEN]byte
 
+var blankProfileKey ProfileKey
+
+func (pk *ProfileKey) IsEmpty() bool {
+	return pk == nil || *pk == blankProfileKey
+}
+
 func (ak *AccessKey) String() string {
-	return string((*ak)[:])
+	return string(ak[:])
 }
 
 func (pv *ProfileKeyVersion) String() string {
-	return string((*pv)[:])
+	return string(pv[:])
 }
 
 func (pk *ProfileKey) Slice() []byte {
 	if pk == nil {
 		return nil
 	}
-	return (*pk)[:]
+	return pk[:]
 }
 
 func (pk *ProfileKey) GetCommitment(u uuid.UUID) (*ProfileKeyCommitment, error) {
diff --git a/pkg/signalmeow/contact.go b/pkg/signalmeow/contact.go
index 7682c990..c4eacf2e 100644
--- a/pkg/signalmeow/contact.go
+++ b/pkg/signalmeow/contact.go
@@ -22,6 +22,7 @@ import (
 	"crypto/sha256"
 	"encoding/binary"
 	"encoding/hex"
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -46,27 +47,23 @@ func (cli *Client) StoreContactDetailsAsContact(ctx context.Context, contactDeta
 		Logger()
 	existingContact, err := cli.Store.ContactStore.LoadContact(ctx, parsedUUID)
 	if err != nil {
-		log.Err(err).Msg("error loading contact")
+		log.Err(err).Msg("Failed to load contact from database")
 		return nil, err
 	}
 	if existingContact == nil {
-		log.Debug().Msg("creating new contact")
 		existingContact = &types.Contact{
 			UUID: parsedUUID,
 		}
-	} else {
-		log.Debug().Msg("updating existing contact")
 	}
 
 	existingContact.E164 = contactDetails.GetNumber()
 	existingContact.ContactName = contactDetails.GetName()
 	if profileKeyString := contactDetails.GetProfileKey(); profileKeyString != nil {
 		profileKey := libsignalgo.ProfileKey(profileKeyString)
-		existingContact.ProfileKey = &profileKey
+		existingContact.Profile.Key = profileKey
 		err = cli.Store.ProfileKeyStore.StoreProfileKey(ctx, existingContact.UUID, profileKey)
 		if err != nil {
-			log.Err(err).Msg("storing profile key")
-			//return *existingContact, nil, err
+			log.Err(err).Msg("Failed to store profile key from contact")
 		}
 	}
 
@@ -86,10 +83,9 @@ func (cli *Client) StoreContactDetailsAsContact(ctx context.Context, contactDeta
 		}
 	}
 
-	log.Debug().Msg("storing contact")
 	storeErr := cli.Store.ContactStore.StoreContact(ctx, *existingContact)
 	if storeErr != nil {
-		log.Err(storeErr).Msg("error storing contact")
+		log.Err(storeErr).Msg("Failed to save contact")
 		return existingContact, storeErr
 	}
 	return existingContact, nil
@@ -104,7 +100,7 @@ func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context,
 
 	existingContact, err := cli.Store.ContactStore.LoadContact(ctx, profileUUID)
 	if err != nil {
-		log.Err(err).Msg("error loading contact")
+		log.Err(err).Msg("Failed to load contact from database")
 		return nil, err
 	}
 	if existingContact == nil {
@@ -118,38 +114,26 @@ func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context,
 	}
 	profile, err := cli.RetrieveProfileByID(ctx, profileUUID)
 	if err != nil {
-		log.Err(err).Msg("error retrieving profile")
-		//return nil, nil, err
-		// Don't return here, we still want to return what we have
+		logLevel := zerolog.ErrorLevel
+		if errors.Is(err, errProfileKeyNotFound) {
+			logLevel = zerolog.DebugLevel
+		}
+		log.WithLevel(logLevel).Err(err).Msg("Failed to fetch profile")
+		// Continue to return contact without profile
 	}
 
 	if profile != nil {
-		if existingContact.ProfileName != profile.Name {
-			existingContact.ProfileName = profile.Name
-			contactChanged = true
-		}
-		if existingContact.ProfileAbout != profile.About {
-			existingContact.ProfileAbout = profile.About
-			contactChanged = true
-		}
-		if existingContact.ProfileAboutEmoji != profile.AboutEmoji {
-			existingContact.ProfileAboutEmoji = profile.AboutEmoji
-			contactChanged = true
-		}
-		if existingContact.ProfileAvatarPath != profile.AvatarPath {
-			existingContact.ProfileAvatarPath = profile.AvatarPath
-			contactChanged = true
-		}
-		if existingContact.ProfileKey == nil || *existingContact.ProfileKey != profile.Key {
-			existingContact.ProfileKey = &profile.Key
+		// Don't bother saving every fetched timestamp to the database, but save if anything else changed
+		if !existingContact.Profile.Equals(profile) || existingContact.Profile.FetchedAt.IsZero() {
 			contactChanged = true
 		}
+		existingContact.Profile = *profile
 	}
 
 	if contactChanged {
-		err := cli.Store.ContactStore.StoreContact(ctx, *existingContact)
+		err = cli.Store.ContactStore.StoreContact(ctx, *existingContact)
 		if err != nil {
-			log.Err(err).Msg("error storing contact")
+			log.Err(err).Msg("Failed to save contact")
 			return nil, err
 		}
 	}
@@ -164,21 +148,18 @@ func (cli *Client) UpdateContactE164(ctx context.Context, uuid uuid.UUID, e164 s
 		Logger()
 	existingContact, err := cli.Store.ContactStore.LoadContact(ctx, uuid)
 	if err != nil {
-		log.Err(err).Msg("error loading contact")
+		log.Err(err).Msg("Failed to load contact from database")
 		return err
 	}
 	if existingContact == nil {
-		log.Debug().Msg("creating new contact")
 		existingContact = &types.Contact{
 			UUID: uuid,
 		}
-	} else {
-		log.Debug().Msg("found existing contact")
 	}
 	if existingContact.E164 == e164 {
 		return nil
 	}
-	log.Debug().Msg("e164 changed for contact")
+	log.Debug().Msg("Contact phone number changed")
 	existingContact.E164 = e164
 	return cli.Store.ContactStore.StoreContact(ctx, *existingContact)
 }
diff --git a/pkg/signalmeow/profile.go b/pkg/signalmeow/profile.go
index 69cb240c..f27db40e 100644
--- a/pkg/signalmeow/profile.go
+++ b/pkg/signalmeow/profile.go
@@ -35,6 +35,7 @@ import (
 	"github.com/rs/zerolog"
 
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
+	"go.mau.fi/mautrix-signal/pkg/signalmeow/types"
 	"go.mau.fi/mautrix-signal/pkg/signalmeow/web"
 )
 
@@ -70,16 +71,8 @@ type ProfileResponse struct {
 	//PaymentAddress     []byte `json:"paymentAddress"`
 }
 
-type Profile struct {
-	Name       string
-	About      string
-	AboutEmoji string
-	AvatarPath string
-	Key        libsignalgo.ProfileKey
-}
-
 type ProfileCache struct {
-	profiles    map[string]*Profile
+	profiles    map[string]*types.Profile
 	errors      map[string]*error
 	lastFetched map[string]time.Time
 }
@@ -118,10 +111,10 @@ func (cli *Client) ProfileKeyForSignalID(ctx context.Context, signalACI uuid.UUI
 
 var errProfileKeyNotFound = errors.New("profile key not found")
 
-func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID) (*Profile, error) {
+func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID) (*types.Profile, error) {
 	if cli.ProfileCache == nil {
 		cli.ProfileCache = &ProfileCache{
-			profiles:    make(map[string]*Profile),
+			profiles:    make(map[string]*types.Profile),
 			errors:      make(map[string]*error),
 			lastFetched: make(map[string]time.Time),
 		}
@@ -144,6 +137,7 @@ func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID)
 	// If we get here, we don't have a cached profile, so fetch it
 	profile, err := cli.fetchProfileByID(ctx, signalID)
 	if err != nil {
+		// TODO this check is wrong and most likely doesn't work, errors shouldn't use string comparisons
 		// If we get a 401 or 5xx error, we should not retry until the cache expires
 		if strings.HasPrefix(err.Error(), "401") || strings.HasPrefix(err.Error(), "5") {
 			cli.ProfileCache.errors[signalID.String()] = &err
@@ -151,9 +145,6 @@ func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID)
 		}
 		return nil, err
 	}
-	if profile == nil {
-		return nil, errProfileKeyNotFound
-	}
 
 	// If we get here, we have a valid profile, so cache it
 	cli.ProfileCache.profiles[signalID.String()] = profile
@@ -162,15 +153,13 @@ func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID)
 	return profile, nil
 }
 
-func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*Profile, error) {
+func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*types.Profile, error) {
 	log := zerolog.Ctx(ctx)
 	profileKey, err := cli.ProfileKeyForSignalID(ctx, signalID)
 	if err != nil {
 		return nil, fmt.Errorf("error getting profile key: %w", err)
-	}
-	if profileKey == nil {
-		log.Warn().Msg("profileKey is nil")
-		return nil, nil
+	} else if profileKey == nil {
+		return nil, errProfileKeyNotFound
 	}
 
 	profileKeyVersion, err := profileKey.GetProfileKeyVersion(signalID)
@@ -211,7 +200,17 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*P
 	if err != nil {
 		return nil, fmt.Errorf("error sending request: %w", err)
 	}
-	log.Trace().Msg("Got profile response")
+	var profile types.Profile
+	profile.FetchedAt = time.Now()
+	logEvt := log.Trace().Uint32("status_code", resp.GetStatus())
+	if logEvt.Enabled() {
+		if json.Valid(resp.Body) {
+			logEvt.RawJSON("response_data", resp.Body)
+		} else {
+			logEvt.Str("invalid_response_data", base64.StdEncoding.EncodeToString(resp.Body))
+		}
+	}
+	logEvt.Msg("Got profile response")
 	if *resp.Status < 200 || *resp.Status >= 300 {
 		return nil, fmt.Errorf("error getting profile (unsuccessful status code %d)", *resp.Status)
 	}
@@ -220,7 +219,6 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*P
 	if err != nil {
 		return nil, fmt.Errorf("error unmarshalling profile response: %w", err)
 	}
-	var profile Profile
 	if len(profileResponse.Name) > 0 {
 		profile.Name, err = decryptString(profileKey, profileResponse.Name)
 		if err != nil {
@@ -241,13 +239,14 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*P
 			return nil, fmt.Errorf("error decrypting profile aboutEmoji: %w", err)
 		}
 	}
+	// TODO store other metadata fields?
 	profile.AvatarPath = profileResponse.Avatar
 	profile.Key = *profileKey
 
 	return &profile, nil
 }
 
-func (cli *Client) DownloadUserAvatar(ctx context.Context, avatarPath string, profileKey *libsignalgo.ProfileKey) ([]byte, error) {
+func (cli *Client) DownloadUserAvatar(ctx context.Context, avatarPath string, profileKey libsignalgo.ProfileKey) ([]byte, error) {
 	username, password := cli.Store.BasicAuthCreds()
 	opts := &web.HTTPReqOpt{
 		Host:     web.CDN1Hostname,
diff --git a/pkg/signalmeow/store/contact_store.go b/pkg/signalmeow/store/contact_store.go
index 2d7247f3..f556124f 100644
--- a/pkg/signalmeow/store/contact_store.go
+++ b/pkg/signalmeow/store/contact_store.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"database/sql"
 	"errors"
+	"time"
 
 	"github.com/google/uuid"
 	"go.mau.fi/util/dbutil"
@@ -50,7 +51,7 @@ const (
 			profile_about,
 			profile_about_emoji,
 			profile_avatar_path,
-			profile_avatar_hash
+			profile_fetched_at
 		FROM signalmeow_contacts
 	`
 	getAllContactsOfUserQuery = getAllContactsQuery + `WHERE our_aci_uuid = $1`
@@ -68,7 +69,7 @@ const (
 			profile_about,
 			profile_about_emoji,
 			profile_avatar_path,
-			profile_avatar_hash
+			profile_fetched_at
 		)
 		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
 		ON CONFLICT (our_aci_uuid, aci_uuid) DO UPDATE SET
@@ -80,7 +81,7 @@ const (
 			profile_about = excluded.profile_about,
 			profile_about_emoji = excluded.profile_about_emoji,
 			profile_avatar_path = excluded.profile_avatar_path,
-			profile_avatar_hash = excluded.profile_avatar_hash
+			profile_fetched_at = excluded.profile_fetched_at
 	`
 	upsertContactPhoneQuery = `
 		INSERT INTO signalmeow_contacts (
@@ -94,9 +95,9 @@ const (
 			profile_about,
 			profile_about_emoji,
 			profile_avatar_path,
-			profile_avatar_hash
+			profile_fetched_at
 		)
-		VALUES ($1, $2, $3, '', '', NULL, '', '', '', '', '')
+		VALUES ($1, $2, $3, '', '', NULL, '', '', '', '', NULL)
 		ON CONFLICT (our_aci_uuid, aci_uuid) DO UPDATE
 			SET e164_number = excluded.e164_number
 	`
@@ -105,26 +106,29 @@ const (
 func scanContact(row dbutil.Scannable) (*types.Contact, error) {
 	var contact types.Contact
 	var profileKey []byte
+	var profileFetchedAt sql.NullInt64
 	err := row.Scan(
 		&contact.UUID,
 		&contact.E164,
 		&contact.ContactName,
 		&contact.ContactAvatar.Hash,
 		&profileKey,
-		&contact.ProfileName,
-		&contact.ProfileAbout,
-		&contact.ProfileAboutEmoji,
-		&contact.ProfileAvatarPath,
-		&contact.ProfileAvatarHash,
+		&contact.Profile.Name,
+		&contact.Profile.About,
+		&contact.Profile.AboutEmoji,
+		&contact.Profile.AvatarPath,
+		&profileFetchedAt,
 	)
 	if errors.Is(err, sql.ErrNoRows) {
 		return nil, nil
 	} else if err != nil {
 		return nil, err
 	}
+	if profileFetchedAt.Valid {
+		contact.Profile.FetchedAt = time.UnixMilli(profileFetchedAt.Int64)
+	}
 	if len(profileKey) != 0 {
-		profileKeyConverted := libsignalgo.ProfileKey(profileKey)
-		contact.ProfileKey = &profileKeyConverted
+		contact.Profile.Key = libsignalgo.ProfileKey(profileKey)
 	}
 	return &contact, err
 }
@@ -146,6 +150,10 @@ func (s *SQLStore) AllContacts(ctx context.Context) ([]*types.Contact, error) {
 }
 
 func (s *SQLStore) StoreContact(ctx context.Context, contact types.Contact) error {
+	var profileKey []byte
+	if contact.Profile.Key.IsEmpty() {
+		profileKey = contact.Profile.Key[:]
+	}
 	_, err := s.db.Exec(
 		ctx,
 		upsertContactQuery,
@@ -154,12 +162,12 @@ func (s *SQLStore) StoreContact(ctx context.Context, contact types.Contact) erro
 		contact.E164,
 		contact.ContactName,
 		contact.ContactAvatar.Hash,
-		contact.ProfileKey.Slice(),
-		contact.ProfileName,
-		contact.ProfileAbout,
-		contact.ProfileAboutEmoji,
-		contact.ProfileAvatarPath,
-		contact.ProfileAvatarHash,
+		profileKey,
+		contact.Profile.Name,
+		contact.Profile.About,
+		contact.Profile.AboutEmoji,
+		contact.Profile.AvatarPath,
+		dbutil.UnixMilliPtr(contact.Profile.FetchedAt),
 	)
 	return err
 }
diff --git a/pkg/signalmeow/store/upgrades/08-profile-fetch-time.sql b/pkg/signalmeow/store/upgrades/08-profile-fetch-time.sql
new file mode 100644
index 00000000..414245ca
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/08-profile-fetch-time.sql
@@ -0,0 +1,11 @@
+-- v6 -> v8: Add profile_fetched_at and make other columns not null
+ALTER TABLE signalmeow_contacts DROP COLUMN profile_avatar_hash;
+ALTER TABLE signalmeow_contacts ADD COLUMN profile_fetched_at BIGINT;
+-- only: postgres until "end only"
+ALTER TABLE signalmeow_contacts ALTER COLUMN e164_number SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN contact_name SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN contact_avatar_hash SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN profile_name SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN profile_about SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN profile_about_emoji SET NOT NULL;
+-- end only postgres
diff --git a/pkg/signalmeow/store/upgrades/08-resync-schema-449.sql b/pkg/signalmeow/store/upgrades/08-resync-schema-449.sql
new file mode 100644
index 00000000..95af7759
--- /dev/null
+++ b/pkg/signalmeow/store/upgrades/08-resync-schema-449.sql
@@ -0,0 +1,12 @@
+-- v7 -> v8: Migration from https://github.com/mautrix/signal/pull/449 to match the new v8 upgrade
+ALTER TABLE signalmeow_contacts DROP COLUMN profile_avatar_hash;
+ALTER TABLE signalmeow_contacts RENAME COLUMN profile_fetch_ts TO profile_fetched_at;
+ALTER TABLE signalmeow_contacts ALTER COLUMN profile_fetched_at DROP DEFAULT;
+ALTER TABLE signalmeow_contacts ALTER COLUMN profile_fetched_at DROP NOT NULL;
+UPDATE signalmeow_contacts SET profile_fetched_at = NULL WHERE profile_fetched_at <= 0;
+ALTER TABLE signalmeow_contacts ALTER COLUMN e164_number SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN contact_name SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN contact_avatar_hash SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN profile_name SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN profile_about SET NOT NULL;
+ALTER TABLE signalmeow_contacts ALTER COLUMN profile_about_emoji SET NOT NULL;
diff --git a/pkg/signalmeow/types/contact.go b/pkg/signalmeow/types/contact.go
index 11a73fb6..f00c89b8 100644
--- a/pkg/signalmeow/types/contact.go
+++ b/pkg/signalmeow/types/contact.go
@@ -17,27 +17,41 @@
 package types
 
 import (
+	"time"
+
 	"github.com/google/uuid"
 
 	"go.mau.fi/mautrix-signal/pkg/libsignalgo"
 )
 
+type Profile struct {
+	Name       string
+	About      string
+	AboutEmoji string
+	AvatarPath string
+	Key        libsignalgo.ProfileKey
+	FetchedAt  time.Time
+}
+
+func (p *Profile) Equals(other *Profile) bool {
+	return p.Name == other.Name &&
+		p.About == other.About &&
+		p.AboutEmoji == other.AboutEmoji &&
+		p.AvatarPath == other.AvatarPath &&
+		p.Key == other.Key
+}
+
 // The Contact struct combines information from two sources:
 // - A Signal "contact": contact info harvested from our user's phone's contact list
 // - A Signal "profile": contact info entered by the target user when registering for Signal
 // Users of this Contact struct should prioritize "contact" information, but fall back
 // to "profile" information if the contact information is not available.
 type Contact struct {
-	UUID              uuid.UUID
-	E164              string
-	ContactName       string
-	ContactAvatar     ContactAvatar
-	ProfileKey        *libsignalgo.ProfileKey
-	ProfileName       string
-	ProfileAbout      string
-	ProfileAboutEmoji string
-	ProfileAvatarPath string
-	ProfileAvatarHash string
+	UUID          uuid.UUID
+	E164          string
+	ContactName   string
+	ContactAvatar ContactAvatar
+	Profile       Profile
 }
 
 type ContactAvatar struct {
diff --git a/puppet.go b/puppet.go
index 34f08ee7..1ecc2409 100644
--- a/puppet.go
+++ b/puppet.go
@@ -245,10 +245,21 @@ func (puppet *Puppet) UpdateInfo(ctx context.Context, source *User) {
 		log.Err(err).Msg("Failed to fetch contact info")
 		return
 	}
+	if !puppet.bridge.Config.Bridge.UseOutdatedProfiles && puppet.ProfileFetchedAt.After(info.Profile.FetchedAt) {
+		log.Debug().
+			Time("contact_profile_fetched_at", info.Profile.FetchedAt).
+			Time("puppet_profile_fetched_at", puppet.ProfileFetchedAt).
+			Msg("Ignoring outdated contact info")
+		return
+	}
 
 	log.Trace().Msg("Updating puppet info")
 
 	update := false
+	if puppet.ProfileFetchedAt.IsZero() && !info.Profile.FetchedAt.IsZero() {
+		update = true
+	}
+	puppet.ProfileFetchedAt = info.Profile.FetchedAt
 	if info.E164 != "" && puppet.Number != info.E164 {
 		puppet.Number = info.E164
 		update = true
@@ -317,10 +328,10 @@ func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *type
 		puppet.AvatarSet = false
 		puppet.AvatarPath = ""
 	} else {
-		if puppet.AvatarPath == info.ProfileAvatarPath && puppet.AvatarSet {
+		if puppet.AvatarPath == info.Profile.AvatarPath && puppet.AvatarSet {
 			return false
 		}
-		if info.ProfileAvatarPath == "" {
+		if info.Profile.AvatarPath == "" {
 			puppet.AvatarURL = id.ContentURI{}
 			puppet.AvatarPath = ""
 			puppet.AvatarHash = ""
@@ -335,10 +346,10 @@ func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *type
 			return true
 		}
 		var err error
-		avatarData, err = source.Client.DownloadUserAvatar(ctx, info.ProfileAvatarPath, info.ProfileKey)
+		avatarData, err = source.Client.DownloadUserAvatar(ctx, info.Profile.AvatarPath, info.Profile.Key)
 		if err != nil {
 			log.Err(err).
-				Str("profile_avatar_path", info.ProfileAvatarPath).
+				Str("profile_avatar_path", info.Profile.AvatarPath).
 				Msg("Failed to download new user avatar")
 			return true
 		}
@@ -354,7 +365,7 @@ func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *type
 		// Path changed, but actual avatar didn't
 		return true
 	}
-	puppet.AvatarPath = info.ProfileAvatarPath
+	puppet.AvatarPath = info.Profile.AvatarPath
 	puppet.AvatarHash = newHash
 	puppet.AvatarSet = false
 	puppet.AvatarURL = id.ContentURI{}


Test patch:
diff --git a/database/upgrades/00-latest.sql b/database/upgrades/00-latest.sql
index ae8e157e..4dec3ff8 100644
--- a/database/upgrades/00-latest.sql
+++ b/database/upgrades/00-latest.sql
@@ -1,4 +1,4 @@
--- v0 -> v19 (compatible with v17+): Latest revision
+-- v0 -> v20 (compatible with v17+): Latest revision
 
 CREATE TABLE portal (
     chat_id     TEXT    NOT NULL,
@@ -33,8 +33,9 @@ CREATE TABLE puppet (
     name_set     BOOLEAN NOT NULL DEFAULT false,
     avatar_set   BOOLEAN NOT NULL DEFAULT false,
 
-    is_registered    BOOLEAN NOT NULL DEFAULT false,
-    contact_info_set BOOLEAN NOT NULL DEFAULT false,
+    is_registered      BOOLEAN NOT NULL DEFAULT false,
+    contact_info_set   BOOLEAN NOT NULL DEFAULT false,
+    profile_fetched_at BIGINT,
 
     custom_mxid  TEXT,
     access_token TEXT NOT NULL,
diff --git a/pkg/signalmeow/store/upgrades/00-latest.sql b/pkg/signalmeow/store/upgrades/00-latest.sql
index 73d02701..37e6e71b 100644
--- a/pkg/signalmeow/store/upgrades/00-latest.sql
+++ b/pkg/signalmeow/store/upgrades/00-latest.sql
@@ -1,4 +1,4 @@
--- v0 -> v6: Latest revision
+-- v0 -> v7: Latest revision
 CREATE TABLE signalmeow_device (
     aci_uuid              TEXT PRIMARY KEY,
 
@@ -76,18 +76,17 @@ CREATE TABLE signalmeow_groups (
 );
 
 CREATE TABLE signalmeow_contacts (
-    our_aci_uuid        TEXT NOT NULL,
-    aci_uuid            TEXT NOT NULL,
-    -- TODO make all fields not null
-    e164_number         TEXT,
-    contact_name        TEXT,
-    contact_avatar_hash TEXT,
+    our_aci_uuid        TEXT   NOT NULL,
+    aci_uuid            TEXT   NOT NULL,
+    e164_number         TEXT   NOT NULL,
+    contact_name        TEXT   NOT NULL,
+    contact_avatar_hash TEXT   NOT NULL,
     profile_key         bytea,
-    profile_name        TEXT,
-    profile_about       TEXT,
-    profile_about_emoji TEXT,
-    profile_avatar_path TEXT NOT NULL DEFAULT '',
-    profile_avatar_hash TEXT,
+    profile_name        TEXT   NOT NULL,
+    profile_about       TEXT   NOT NULL,
+    profile_about_emoji TEXT   NOT NULL,
+    profile_avatar_path TEXT   NOT NULL,
+    profile_fetched_at  BIGINT,
 
     PRIMARY KEY (our_aci_uuid, aci_uuid),
     FOREIGN KEY (our_aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE