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
29 changes: 29 additions & 0 deletions pkg/connector/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ const (
MediaRequestMethodLocalTime MediaRequestMethod = "local_time"
)

// NameQuality represents the quality/priority of a display name source.
// Higher values are better quality and should not be overwritten by lower quality names.
const (
NameQualityNone int = 0 // No name available
NameQualityPushName int = 1 // User's self-set push name (can change frequently)
NameQualityPhone int = 2 // Phone number (stable but not human-readable)
NameQualityBusinessName int = 3 // WhatsApp Business account name (stable)
NameQualityFullName int = 4 // Contact list name (stable and user-preferred)
)

//go:embed example-config.yaml
var ExampleConfig string

Expand Down Expand Up @@ -188,3 +198,22 @@ func (wa *WhatsAppConnector) GetConfig() (string, any, up.Upgrader) {
Base: ExampleConfig,
}
}

// GetNameQuality determines the quality of the display name based on available data.
// This is used to prevent overwriting high-quality names (like contact list names)
// with lower-quality names (like push names that can change frequently).
func GetNameQuality(contact types.ContactInfo, phone string) int {
if contact.FullName != "" {
return NameQualityFullName
}
if contact.BusinessName != "" {
return NameQualityBusinessName
}
if phone != "" {
return NameQualityPhone
}
if contact.PushName != "" {
return NameQualityPushName
}
return NameQualityNone
}
6 changes: 3 additions & 3 deletions pkg/connector/handlewhatsapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,13 +664,13 @@ func (wa *WhatsAppClient) syncGhost(jid types.JID, reason string, pictureID *str
if pictureID != nil && *pictureID != "" && ghost.AvatarID == networkid.AvatarID(*pictureID) {
return
}
userInfo, err := wa.getUserInfo(ctx, jid, pictureID != nil)
userInfo, quality, err := wa.getUserInfo(ctx, jid, pictureID != nil)
if err != nil {
log.Err(err).Msg("Failed to get user info")
} else {
ghost.UpdateInfo(ctx, userInfo)
updateGhostWithQualityCheck(ctx, ghost, userInfo, quality)
log.Debug().Msg("Synced ghost info")
wa.syncAltGhostWithInfo(ctx, jid, userInfo)
wa.syncAltGhostWithInfo(ctx, jid, userInfo, quality)
}
go wa.syncRemoteProfile(ctx, ghost)
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/connector/startchat.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,11 @@ func (wa *WhatsAppClient) getContactList(ctx context.Context, filter string, onl
continue
}
ghost, _ := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid))
userInfo, _ := wa.contactToUserInfo(ctx, jid, contactInfo, false)
resp = append(resp, &bridgev2.ResolveIdentifierResponse{
Ghost: ghost,
UserID: waid.MakeUserID(jid),
UserInfo: wa.contactToUserInfo(ctx, jid, contactInfo, false),
UserInfo: userInfo,
Chat: &bridgev2.CreateChatResponse{PortalKey: wa.makeWAPortalKey(jid)},
})
}
Expand Down
80 changes: 66 additions & 14 deletions pkg/connector/userinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,13 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID
log.Warn().Stringer("jid", jid).Msg("Didn't get info for puppet in background sync")
continue
}
userInfo, err := wa.getUserInfo(ctx, jid, info.PictureID != "" && string(ghost.AvatarID) != info.PictureID)
userInfo, quality, err := wa.getUserInfo(ctx, jid, info.PictureID != "" && string(ghost.AvatarID) != info.PictureID)
if err != nil {
log.Err(err).Stringer("jid", jid).Msg("Failed to get user info for puppet in background sync")
continue
}
ghost.UpdateInfo(ctx, userInfo)
wa.syncAltGhostWithInfo(ctx, jid, userInfo)
updateGhostWithQualityCheck(ctx, ghost, userInfo, quality)
wa.syncAltGhostWithInfo(ctx, jid, userInfo, quality)
}
}

Expand All @@ -177,18 +177,27 @@ func (wa *WhatsAppClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost
return nil, nil
}
jid := waid.ParseUserID(ghost.ID)
return wa.getUserInfo(ctx, jid, ghost.AvatarID == "")
ui, quality, err := wa.getUserInfo(ctx, jid, ghost.AvatarID == "")
if err != nil {
return nil, err
}
// For initial fetch, always set the quality (no existing quality to compare against)
if ui != nil {
ui.ExtraUpdates = bridgev2.MergeExtraUpdaters(ui.ExtraUpdates, makeQualityUpdater(quality))
}
return ui, nil
}

func (wa *WhatsAppClient) getUserInfo(ctx context.Context, jid types.JID, fetchAvatar bool) (*bridgev2.UserInfo, error) {
func (wa *WhatsAppClient) getUserInfo(ctx context.Context, jid types.JID, fetchAvatar bool) (*bridgev2.UserInfo, int, error) {
contact, err := wa.GetStore().Contacts.GetContact(ctx, jid)
if err != nil {
return nil, err
return nil, 0, err
}
return wa.contactToUserInfo(ctx, jid, contact, fetchAvatar), nil
ui, quality := wa.contactToUserInfo(ctx, jid, contact, fetchAvatar)
return ui, quality, nil
}

func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID, contact types.ContactInfo, getAvatar bool) *bridgev2.UserInfo {
func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID, contact types.ContactInfo, getAvatar bool) (*bridgev2.UserInfo, int) {
if jid == types.MetaAIJID && contact.PushName == jid.User {
contact.PushName = "Meta AI"
} else if jid == types.LegacyPSAJID || jid == types.PSAJID {
Expand Down Expand Up @@ -256,6 +265,7 @@ func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID,
} else if altJID.Server == types.DefaultUserServer {
phone = "+" + altJID.User
}
nameQuality := GetNameQuality(contact, phone)
ui := &bridgev2.UserInfo{
Name: ptr.Ptr(wa.Main.Config.FormatDisplayname(jid, phone, contact)),
IsBot: ptr.Ptr(jid.IsBot()),
Expand All @@ -269,7 +279,7 @@ func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID,
if getAvatar {
ui.ExtraUpdates = bridgev2.MergeExtraUpdaters(ui.ExtraUpdates, wa.fetchGhostAvatar)
}
return ui
return ui, nameQuality
}

func updateGhostLastSyncAt(_ context.Context, ghost *bridgev2.Ghost) bool {
Expand All @@ -279,6 +289,48 @@ func updateGhostLastSyncAt(_ context.Context, ghost *bridgev2.Ghost) bool {
return forceSave
}

// makeQualityUpdater creates an ExtraUpdater that stores the name quality in ghost metadata.
func makeQualityUpdater(quality int) bridgev2.ExtraUpdater[*bridgev2.Ghost] {
return func(_ context.Context, ghost *bridgev2.Ghost) bool {
meta := ghost.Metadata.(*waid.GhostMetadata)
if meta.NameQuality != quality {
meta.NameQuality = quality
return true // force save
}
return false
}
}

// shouldUpdateName checks if a name update should proceed based on quality comparison.
// Returns true if the new quality is equal to or better than the current quality.
// Never allows updating to an empty name (quality 0).
func shouldUpdateName(ghost *bridgev2.Ghost, newQuality int) bool {
// Never update to empty name
if newQuality == NameQualityNone {
return false
}
meta := ghost.Metadata.(*waid.GhostMetadata)
return newQuality >= meta.NameQuality
}

// updateGhostWithQualityCheck updates a ghost's info while checking name quality.
// If the new name quality is lower than the current quality, the name update is skipped.
func updateGhostWithQualityCheck(ctx context.Context, ghost *bridgev2.Ghost, ui *bridgev2.UserInfo, quality int) {
if !shouldUpdateName(ghost, quality) {
// Skip name update by setting Name to nil, but keep other updates
ui.Name = nil
zerolog.Ctx(ctx).Debug().
Str("ghost_id", string(ghost.ID)).
Int("current_quality", ghost.Metadata.(*waid.GhostMetadata).NameQuality).
Int("new_quality", quality).
Msg("Skipping name update due to lower quality")
} else {
// Include quality updater if we're updating the name
ui.ExtraUpdates = bridgev2.MergeExtraUpdaters(ui.ExtraUpdates, makeQualityUpdater(quality))
}
ghost.UpdateInfo(ctx, ui)
}

var expiryRegex = regexp.MustCompile("oe=([0-9A-Fa-f]+)")

func avatarInfoToCacheEntry(ctx context.Context, jid types.JID, avatar *types.ProfilePictureInfo) *wadb.AvatarCacheEntry {
Expand Down Expand Up @@ -395,14 +447,14 @@ func (wa *WhatsAppClient) resyncContacts(forceAvatarSync, automatic bool) {
} else if contact, err := contactStore.GetContact(ctx, jid); err != nil {
log.Err(err).Stringer("jid", jid).Msg("Failed to get contact info")
} else {
userInfo := wa.contactToUserInfo(ctx, jid, contact, forceAvatarSync || ghost.AvatarID == "")
ghost.UpdateInfo(ctx, userInfo)
wa.syncAltGhostWithInfo(ctx, jid, userInfo)
userInfo, quality := wa.contactToUserInfo(ctx, jid, contact, forceAvatarSync || ghost.AvatarID == "")
updateGhostWithQualityCheck(ctx, ghost, userInfo, quality)
wa.syncAltGhostWithInfo(ctx, jid, userInfo, quality)
}
}
}

func (wa *WhatsAppClient) syncAltGhostWithInfo(ctx context.Context, jid types.JID, info *bridgev2.UserInfo) {
func (wa *WhatsAppClient) syncAltGhostWithInfo(ctx context.Context, jid types.JID, info *bridgev2.UserInfo, quality int) {
log := zerolog.Ctx(ctx)
var altJID types.JID
var err error
Expand All @@ -427,7 +479,7 @@ func (wa *WhatsAppClient) syncAltGhostWithInfo(ctx context.Context, jid types.JI
Msg("Failed to get ghost for alternate JID")
return
}
ghost.UpdateInfo(ctx, info)
updateGhostWithQualityCheck(ctx, ghost, info, quality)
log.Debug().
Stringer("jid", jid).
Stringer("alternate_jid", altJID).
Expand Down
3 changes: 2 additions & 1 deletion pkg/waid/dbmeta.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,6 @@ type PortalMetadata struct {
}

type GhostMetadata struct {
LastSync jsontime.Unix `json:"last_sync,omitempty"`
LastSync jsontime.Unix `json:"last_sync,omitempty"`
NameQuality int `json:"name_quality,omitempty"`
}