diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..0aa3d9ed --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,62 @@ +name: Build and Push Docker Image + +# This workflow builds the mautrix-whatsapp image with the +# `name-quality-tracking` patch on top of the upstream base it was +# rebased onto. Triggers on push to the name-quality-tracking branch. +# +# Tags pushed: +# :name-quality - rolling pointer to the latest build +# :sha- - immutable per commit +# :v-name-quality - immutable, encodes upstream version + +on: + push: + branches: + - name-quality-tracking + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need tags for closest-tag detection + + - name: Compute upstream base tag + id: base + run: | + # Find the latest upstream v* tag that is ancestor of HEAD + BASE=$(git tag -l "v*" --sort=-v:refname | head -50 | while read t; do + if git merge-base --is-ancestor "$t" HEAD 2>/dev/null; then + echo "$t"; break + fi + done) + echo "base=${BASE:-unknown}" >> "$GITHUB_OUTPUT" + SHORT=$(git rev-parse --short HEAD) + echo "short=${SHORT}" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ghcr.io/jlxq0/whatsapp:name-quality + ghcr.io/jlxq0/whatsapp:sha-${{ steps.base.outputs.short }} + ghcr.io/jlxq0/whatsapp:${{ steps.base.outputs.base }}-name-quality + labels: | + org.opencontainers.image.source=https://github.com/jlxq0/whatsapp + org.opencontainers.image.description=mautrix-whatsapp with name-quality-tracking patch + org.opencontainers.image.version=${{ steps.base.outputs.base }}-name-quality diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 24456475..9f959c4a 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -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 @@ -193,3 +203,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 +} diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index eb42f439..d92b8a5e 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -663,13 +663,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) } diff --git a/pkg/connector/startchat.go b/pkg/connector/startchat.go index a0b06922..cdb51842 100644 --- a/pkg/connector/startchat.go +++ b/pkg/connector/startchat.go @@ -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)}, }) } diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index aa849384..6ba14aaa 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -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) } } @@ -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 { @@ -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()), @@ -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 { @@ -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 { @@ -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 @@ -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). diff --git a/pkg/waid/dbmeta.go b/pkg/waid/dbmeta.go index 27851056..66bd3c7e 100644 --- a/pkg/waid/dbmeta.go +++ b/pkg/waid/dbmeta.go @@ -123,5 +123,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"` }