diff --git a/config/bridge.go b/config/bridge.go index 5adc6351..495995d1 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -169,12 +169,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/pkg/signalmeow/contact.go b/pkg/signalmeow/contact.go index 7682c990..6dbca58c 100644 --- a/pkg/signalmeow/contact.go +++ b/pkg/signalmeow/contact.go @@ -62,7 +62,7 @@ func (cli *Client) StoreContactDetailsAsContact(ctx context.Context, contactDeta 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") @@ -95,17 +95,17 @@ func (cli *Client) StoreContactDetailsAsContact(ctx context.Context, contactDeta return existingContact, nil } -func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context, profileUUID uuid.UUID) (*types.Contact, error) { +func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context, profileUUID uuid.UUID) (existingContact *types.Contact, otherSourceUUID uuid.UUID, err error) { log := zerolog.Ctx(ctx).With(). Str("action", "fetch contact then try and update with profile"). Stringer("profile_uuid", profileUUID). Logger() contactChanged := false - existingContact, err := cli.Store.ContactStore.LoadContact(ctx, profileUUID) + existingContact, err = cli.Store.ContactStore.LoadContact(ctx, profileUUID) if err != nil { log.Err(err).Msg("error loading contact") - return nil, err + return } if existingContact == nil { log.Debug().Msg("creating new contact") @@ -116,44 +116,49 @@ func (cli *Client) fetchContactThenTryAndUpdateWithProfile(ctx context.Context, } else { log.Debug().Msg("updating existing contact") } - profile, err := cli.RetrieveProfileByID(ctx, profileUUID) - if err != nil { - log.Err(err).Msg("error retrieving profile") - //return nil, nil, err + profile, lastFetched, fetchErr := cli.RetrieveProfileByID(ctx, profileUUID) + if fetchErr != nil { + log.Err(fetchErr).Msg("error retrieving profile") // Don't return here, we still want to return what we have - } - - if profile != nil { - if existingContact.ProfileName != profile.Name { - existingContact.ProfileName = profile.Name + } else if profile != nil { + if existingContact.Profile.Name != profile.Name { + existingContact.Profile.Name = profile.Name contactChanged = true } - if existingContact.ProfileAbout != profile.About { - existingContact.ProfileAbout = profile.About + if existingContact.Profile.About != profile.About { + existingContact.Profile.About = profile.About contactChanged = true } - if existingContact.ProfileAboutEmoji != profile.AboutEmoji { - existingContact.ProfileAboutEmoji = profile.AboutEmoji + if existingContact.Profile.AboutEmoji != profile.AboutEmoji { + existingContact.Profile.AboutEmoji = profile.AboutEmoji contactChanged = true } - if existingContact.ProfileAvatarPath != profile.AvatarPath { - existingContact.ProfileAvatarPath = profile.AvatarPath + if existingContact.Profile.AvatarPath != profile.AvatarPath { + existingContact.Profile.AvatarPath = profile.AvatarPath contactChanged = true } - if existingContact.ProfileKey == nil || *existingContact.ProfileKey != profile.Key { - existingContact.ProfileKey = &profile.Key + if existingContact.Profile.Key == nil || *existingContact.Profile.Key != profile.Key { + existingContact.Profile.Key = &profile.Key contactChanged = true } } if contactChanged { - err := cli.Store.ContactStore.StoreContact(ctx, *existingContact) + existingContact.ProfileFetchTs = lastFetched.UnixMilli() + err = cli.Store.ContactStore.StoreContact(ctx, *existingContact) if err != nil { log.Err(err).Msg("error storing contact") - return nil, err + return } } - return existingContact, nil + + if fetchErr != nil { + otherSourceUUID, fetchErr = cli.Store.ContactStore.UpdateContactWithLatestProfile(ctx, existingContact) + if fetchErr != nil { + log.Err(fetchErr).Msg("error retrieving latest profile for contact from other users") + } + } + return } func (cli *Client) UpdateContactE164(ctx context.Context, uuid uuid.UUID, e164 string) error { @@ -183,7 +188,7 @@ func (cli *Client) UpdateContactE164(ctx context.Context, uuid uuid.UUID, e164 s return cli.Store.ContactStore.StoreContact(ctx, *existingContact) } -func (cli *Client) ContactByID(ctx context.Context, uuid uuid.UUID) (*types.Contact, error) { +func (cli *Client) ContactByID(ctx context.Context, uuid uuid.UUID) (contact *types.Contact, otherSourceUUID uuid.UUID, err error) { return cli.fetchContactThenTryAndUpdateWithProfile(ctx, uuid) } @@ -196,7 +201,7 @@ func (cli *Client) ContactByE164(ctx context.Context, e164 string) (*types.Conta if contact == nil { return nil, nil } - contact, err = cli.fetchContactThenTryAndUpdateWithProfile(ctx, contact.UUID) + contact, _, err = cli.fetchContactThenTryAndUpdateWithProfile(ctx, contact.UUID) return contact, err } diff --git a/pkg/signalmeow/profile.go b/pkg/signalmeow/profile.go index 69cb240c..c8aef4db 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" ) @@ -71,11 +72,8 @@ type ProfileResponse struct { } type Profile struct { - Name string - About string - AboutEmoji string - AvatarPath string - Key libsignalgo.ProfileKey + types.ProfileFields + Key libsignalgo.ProfileKey } type ProfileCache struct { @@ -118,7 +116,7 @@ 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) (*Profile, time.Time, error) { if cli.ProfileCache == nil { cli.ProfileCache = &ProfileCache{ profiles: make(map[string]*Profile), @@ -133,33 +131,34 @@ func (cli *Client) RetrieveProfileByID(ctx context.Context, signalID uuid.UUID) if ok && time.Since(lastFetched) < 1*time.Hour { profile, ok := cli.ProfileCache.profiles[signalID.String()] if ok { - return profile, nil + return profile, lastFetched, nil } err, ok := cli.ProfileCache.errors[signalID.String()] if ok { - return nil, *err + return nil, lastFetched, *err } } // If we get here, we don't have a cached profile, so fetch it profile, err := cli.fetchProfileByID(ctx, signalID) + lastFetched = time.Now() if err != nil { // 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 - cli.ProfileCache.lastFetched[signalID.String()] = time.Now() + cli.ProfileCache.lastFetched[signalID.String()] = lastFetched } - return nil, err + return nil, lastFetched, err } if profile == nil { - return nil, errProfileKeyNotFound + return nil, lastFetched, errProfileKeyNotFound } // If we get here, we have a valid profile, so cache it cli.ProfileCache.profiles[signalID.String()] = profile - cli.ProfileCache.lastFetched[signalID.String()] = time.Now() + cli.ProfileCache.lastFetched[signalID.String()] = lastFetched - return profile, nil + return profile, lastFetched, nil } func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*Profile, error) { @@ -248,6 +247,9 @@ func (cli *Client) fetchProfileByID(ctx context.Context, signalID uuid.UUID) (*P } func (cli *Client) DownloadUserAvatar(ctx context.Context, avatarPath string, profileKey *libsignalgo.ProfileKey) ([]byte, error) { + if profileKey == nil { + return nil, fmt.Errorf("failed to prepare request: profileKey is nil") + } 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..0d2f610d 100644 --- a/pkg/signalmeow/store/contact_store.go +++ b/pkg/signalmeow/store/contact_store.go @@ -31,6 +31,7 @@ import ( type ContactStore interface { LoadContact(ctx context.Context, theirUUID uuid.UUID) (*types.Contact, error) LoadContactByE164(ctx context.Context, e164 string) (*types.Contact, error) + UpdateContactWithLatestProfile(ctx context.Context, contact *types.Contact) (sourceUUID uuid.UUID, err error) StoreContact(ctx context.Context, contact types.Contact) error AllContacts(ctx context.Context) ([]*types.Contact, error) UpdatePhone(ctx context.Context, theirUUID uuid.UUID, newE164 string) error @@ -50,7 +51,8 @@ const ( profile_about, profile_about_emoji, profile_avatar_path, - profile_avatar_hash + profile_avatar_hash, + profile_fetch_ts FROM signalmeow_contacts ` getAllContactsOfUserQuery = getAllContactsQuery + `WHERE our_aci_uuid = $1` @@ -68,9 +70,10 @@ const ( profile_about, profile_about_emoji, profile_avatar_path, - profile_avatar_hash + profile_avatar_hash, + profile_fetch_ts ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (our_aci_uuid, aci_uuid) DO UPDATE SET e164_number = excluded.e164_number, contact_name = excluded.contact_name, @@ -80,7 +83,8 @@ 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_avatar_hash = excluded.profile_avatar_hash, + profile_fetch_ts = excluded.profile_fetch_ts ` upsertContactPhoneQuery = ` INSERT INTO signalmeow_contacts ( @@ -94,9 +98,10 @@ const ( profile_about, profile_about_emoji, profile_avatar_path, - profile_avatar_hash + profile_avatar_hash, + profile_fetch_ts ) - VALUES ($1, $2, $3, '', '', NULL, '', '', '', '', '') + VALUES ($1, $2, $3, '', '', NULL, '', '', '', '', '', 0) ON CONFLICT (our_aci_uuid, aci_uuid) DO UPDATE SET e164_number = excluded.e164_number ` @@ -111,11 +116,12 @@ func scanContact(row dbutil.Scannable) (*types.Contact, error) { &contact.ContactName, &contact.ContactAvatar.Hash, &profileKey, - &contact.ProfileName, - &contact.ProfileAbout, - &contact.ProfileAboutEmoji, - &contact.ProfileAvatarPath, + &contact.Profile.Name, + &contact.Profile.About, + &contact.Profile.AboutEmoji, + &contact.Profile.AvatarPath, &contact.ProfileAvatarHash, + &contact.ProfileFetchTs, ) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -124,7 +130,7 @@ func scanContact(row dbutil.Scannable) (*types.Contact, error) { } if len(profileKey) != 0 { profileKeyConverted := libsignalgo.ProfileKey(profileKey) - contact.ProfileKey = &profileKeyConverted + contact.Profile.Key = &profileKeyConverted } return &contact, err } @@ -137,6 +143,42 @@ func (s *SQLStore) LoadContactByE164(ctx context.Context, e164 string) (*types.C return scanContact(s.db.QueryRow(ctx, getContactByPhoneQuery, s.ACI, e164)) } +func (s *SQLStore) UpdateContactWithLatestProfile(ctx context.Context, contact *types.Contact) (sourceUUID uuid.UUID, err error) { + var profileKey []byte + err = s.db.QueryRow( + ctx, + `SELECT + profile_key, + profile_name, + profile_about, + profile_about_emoji, + profile_avatar_path, + our_aci_uuid + FROM signalmeow_contacts + WHERE + our_aci_uuid <> $1 AND + aci_uuid = $2 AND + LENGTH(COALESCE(profile_key, '')) > 0 + ORDER BY profile_fetch_ts DESC LIMIT 1`, + s.ACI, + contact.UUID, + ).Scan( + &profileKey, + &contact.Profile.Name, + &contact.Profile.About, + &contact.Profile.AboutEmoji, + &contact.Profile.AvatarPath, + &sourceUUID, + ) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } else if err == nil { + profileKeyConverted := libsignalgo.ProfileKey(profileKey) + contact.Profile.Key = &profileKeyConverted + } + return +} + func (s *SQLStore) AllContacts(ctx context.Context) ([]*types.Contact, error) { rows, err := s.db.Query(ctx, getAllContactsOfUserQuery, s.ACI) if err != nil { @@ -154,12 +196,13 @@ 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.Profile.Key.Slice(), + contact.Profile.Name, + contact.Profile.About, + contact.Profile.AboutEmoji, + contact.Profile.AvatarPath, contact.ProfileAvatarHash, + contact.ProfileFetchTs, ) return err } diff --git a/pkg/signalmeow/store/upgrades/00-latest.sql b/pkg/signalmeow/store/upgrades/00-latest.sql index 73d02701..b8161bc4 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, @@ -88,6 +88,7 @@ CREATE TABLE signalmeow_contacts ( profile_about_emoji TEXT, profile_avatar_path TEXT NOT NULL DEFAULT '', profile_avatar_hash TEXT, + profile_fetch_ts BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (our_aci_uuid, aci_uuid), FOREIGN KEY (our_aci_uuid) REFERENCES signalmeow_device (aci_uuid) ON DELETE CASCADE ON UPDATE CASCADE diff --git a/pkg/signalmeow/store/upgrades/07-profile-fetch-timestamps.sql b/pkg/signalmeow/store/upgrades/07-profile-fetch-timestamps.sql new file mode 100644 index 00000000..9b632a22 --- /dev/null +++ b/pkg/signalmeow/store/upgrades/07-profile-fetch-timestamps.sql @@ -0,0 +1,2 @@ +-- v7 (compatible with v5+): Save profile fetch timestamp +ALTER TABLE signalmeow_contacts ADD COLUMN profile_fetch_ts BIGINT NOT NULL DEFAULT 0; diff --git a/pkg/signalmeow/types/contact.go b/pkg/signalmeow/types/contact.go index 11a73fb6..ea6088f1 100644 --- a/pkg/signalmeow/types/contact.go +++ b/pkg/signalmeow/types/contact.go @@ -32,12 +32,14 @@ type Contact struct { E164 string ContactName string ContactAvatar ContactAvatar - ProfileKey *libsignalgo.ProfileKey - ProfileName string - ProfileAbout string - ProfileAboutEmoji string - ProfileAvatarPath string + Profile ContactProfile ProfileAvatarHash string + ProfileFetchTs int64 +} + +type ContactProfile struct { + ProfileFields + Key *libsignalgo.ProfileKey } type ContactAvatar struct { diff --git a/pkg/signalmeow/types/profile.go b/pkg/signalmeow/types/profile.go new file mode 100644 index 00000000..22d2d0e6 --- /dev/null +++ b/pkg/signalmeow/types/profile.go @@ -0,0 +1,24 @@ +// mautrix-signal - A Matrix-signal puppeting bridge. +// Copyright (C) 2023 Scott Weber, Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package types + +type ProfileFields struct { + Name string + About string + AboutEmoji string + AvatarPath string +} diff --git a/puppet.go b/puppet.go index 34f08ee7..2bc020de 100644 --- a/puppet.go +++ b/puppet.go @@ -240,11 +240,23 @@ func (puppet *Puppet) UpdateInfo(ctx context.Context, source *User) { ctx = log.WithContext(ctx) var err error log.Debug().Msg("Fetching contact info to update puppet") - info, err := source.Client.ContactByID(ctx, puppet.SignalID) + info, sourceUUID, err := source.Client.ContactByID(ctx, puppet.SignalID) if err != nil { log.Err(err).Msg("Failed to fetch contact info") return } + if sourceUUID != uuid.Nil { + source = puppet.bridge.GetUserBySignalID(sourceUUID) + if source == nil || source.Client == nil { + log.Warn(). + Stringer("source_uuid", sourceUUID). + Msg("No fallback user for profile info update") + return + } + log.Debug(). + Stringer("source_mxid", source.MXID). + Msg("Using fallback user for profile info update") + } log.Trace().Msg("Updating puppet info") @@ -314,13 +326,13 @@ func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *type // TODO what to do? 🤔 return false } - puppet.AvatarSet = false puppet.AvatarPath = "" + puppet.AvatarHash = info.ContactAvatar.Hash } 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,27 +347,33 @@ 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 } avatarContentType = http.DetectContentType(avatarData) + puppet.AvatarPath = info.Profile.AvatarPath + hash := sha256.Sum256(avatarData) + newHash := hex.EncodeToString(hash[:]) + if puppet.AvatarHash == newHash && puppet.AvatarSet { + log.Debug(). + Str("avatar_hash", newHash). + Str("new_avatar_path", puppet.AvatarPath). + Msg("Avatar path changed, but hash didn't") + // Path changed, but actual avatar didn't + return true + } + puppet.AvatarHash = newHash + info.ProfileAvatarHash = newHash + source.Client.Store.ContactStore.StoreContact(ctx, *info) + err = source.Client.Store.ContactStore.StoreContact(ctx, *info) + if err != nil { + log.Warn().Err(err).Msg("error updating contact's profile avatar hash") + } } - hash := sha256.Sum256(avatarData) - newHash := hex.EncodeToString(hash[:]) - if puppet.AvatarHash == newHash && puppet.AvatarSet { - log.Debug(). - Str("avatar_hash", newHash). - Str("new_avatar_path", puppet.AvatarPath). - Msg("Avatar path changed, but hash didn't") - // Path changed, but actual avatar didn't - return true - } - puppet.AvatarPath = info.ProfileAvatarPath - puppet.AvatarHash = newHash puppet.AvatarSet = false puppet.AvatarURL = id.ContentURI{} resp, err := puppet.DefaultIntent().UploadBytes(ctx, avatarData, avatarContentType) @@ -372,7 +390,7 @@ func (puppet *Puppet) updateAvatar(ctx context.Context, source *User, info *type return true } log.Debug(). - Str("avatar_hash", newHash). + Str("avatar_hash", puppet.AvatarHash). Stringer("avatar_mxc", resp.ContentURI). Msg("Avatar updated successfully") puppet.AvatarSet = true