From 1fc894948c033c300529bd96a282a9e7159ef543 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 12 Oct 2026 12:34:18 +0000 Subject: [PATCH 01/56] handlewhatsapp: fix target message ID for delete for me in DMs (#975) --- pkg/connector/events.go | 14 +------------- pkg/connector/handlewhatsapp.go | 2 +- pkg/connector/id.go | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/pkg/connector/events.go b/pkg/connector/events.go index bd328c59..743492d1 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -469,19 +469,7 @@ func (evt *WAMediaRetry) AddLogContext(c zerolog.Context) zerolog.Context { } func (evt *WAMediaRetry) getRealSender() types.JID { - sender := evt.SenderID - if sender.IsEmpty() { - if evt.FromMe { - if evt.ChatID.Server == types.HiddenUserServer { - sender = evt.wa.GetLID().ToNonAD() - } else { - sender = evt.wa.JID.ToNonAD() - } - } else if evt.ChatID.Server == types.DefaultUserServer || evt.ChatID.Server == types.HiddenUserServer || evt.ChatID.Server == types.BotServer { - sender = evt.ChatID.ToNonAD() - } - } - return sender + return evt.wa.getRealSender(evt.ChatID, evt.SenderID, evt.FromMe) } func (evt *WAMediaRetry) GetSender() bridgev2.EventSender { diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index 2b30fdde..74606abe 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -721,7 +721,7 @@ func (wa *WhatsAppClient) handleWADeleteChat(ctx context.Context, evt *events.De func (wa *WhatsAppClient) handleWADeleteForMe(ctx context.Context, evt *events.DeleteForMe) bool { chatJID := wa.maybeConvertJIDToLID(ctx, evt.ChatJID) - senderJID := wa.maybeConvertJIDToLID(ctx, evt.SenderJID) + senderJID := wa.getRealSender(chatJID, wa.maybeConvertJIDToLID(ctx, evt.SenderJID), evt.IsFromMe) return wa.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventMessageRemove, diff --git a/pkg/connector/id.go b/pkg/connector/id.go index c0aa06b0..43e949a6 100644 --- a/pkg/connector/id.go +++ b/pkg/connector/id.go @@ -82,3 +82,18 @@ func (wa *WhatsAppClient) maybeConvertJIDToLID(ctx context.Context, jid types.JI } return jid } + +func (wa *WhatsAppClient) getRealSender(chat, sender types.JID, fromMe bool) types.JID { + if sender.IsEmpty() { + if fromMe { + if chat.Server == types.HiddenUserServer { + sender = wa.GetLID().ToNonAD() + } else { + sender = wa.JID.ToNonAD() + } + } else if chat.Server == types.DefaultUserServer || chat.Server == types.HiddenUserServer || chat.Server == types.BotServer { + sender = chat.ToNonAD() + } + } + return sender +} From 7fb697fb89b45a42e1e5ee7a668bc0ee6a24341a Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 13 Oct 2026 11:22:06 +0000 Subject: [PATCH 02/56] handlematrix: unfollow channels on leave and delete (#977) --- pkg/connector/handlematrix.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index ee3d70a6..933c43a0 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -439,6 +439,12 @@ func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridg default: return nil, nil } + } else if portalJID.Server == types.NewsletterServer { + if msg.Type != bridgev2.Leave { + return nil, fmt.Errorf("cannot change channel members") + } + defer wa.mcTrack(msg, time.Now(), &retErr) + return nil, wa.Client.UnfollowNewsletter(ctx, portalJID) } changes := make([]types.JID, 1) @@ -690,7 +696,10 @@ func (wa *WhatsAppClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridg if err != nil { return err } - if chatJID.Server == types.GroupServer { + if chatJID.Server == types.NewsletterServer { + defer wa.mcTrack(msg, time.Now(), &retErr) + return wa.Client.UnfollowNewsletter(ctx, chatJID) + } else if chatJID.Server == types.GroupServer { memberInfo, err := wa.Main.Bridge.Matrix.GetMemberInfo(ctx, msg.Portal.MXID, wa.UserLogin.UserMXID) if err != nil { return fmt.Errorf("failed to get own member info: %w", err) From bf98cabad21ae37eacbe0c501b34abd011a59c8c Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 13 Oct 2026 11:46:49 +0000 Subject: [PATCH 03/56] chatinfo: don't overwrite mutes while regular_high recovery is pending (#978) --- pkg/connector/chatinfo.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index 3984b5f9..d26aeaef 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -10,6 +10,7 @@ import ( "go.mau.fi/util/jsontime" "go.mau.fi/util/ptr" "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" "maunium.net/go/mautrix/bridgev2" @@ -116,9 +117,16 @@ func (wa *WhatsAppClient) applyChatSettings(ctx context.Context, chatID types.JI return } } - info.UserLocal = &bridgev2.UserLocalPortalInfo{ - MutedUntil: ptr.Ptr(chat.MutedUntil), + wa.appStateRecoveryLock.Lock() + _, muteStateBroken := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).AppStateRecoveryAttempted[appstate.WAPatchRegularHigh] + wa.appStateRecoveryLock.Unlock() + userLocal := &bridgev2.UserLocalPortalInfo{} + if !muteStateBroken { + userLocal.MutedUntil = ptr.Ptr(chat.MutedUntil) + } else if info.UserLocal != nil { + userLocal.MutedUntil = info.UserLocal.MutedUntil } + info.UserLocal = userLocal if chat.Pinned { info.UserLocal.Tag = ptr.Ptr(wa.Main.Config.PinnedTag) } else if chat.Archived { From 73b70f2753a4a0fbede6f2a1da7946259097769a Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 13 Oct 2026 17:32:09 +0300 Subject: [PATCH 04/56] chatinfo,handlematrix: allow encrypted poll votes in read-only groups Permissions are now checked by whatsmeow to return appropriate errors --- go.mod | 2 +- go.sum | 4 ++-- pkg/connector/chatinfo.go | 16 ++++++++++------ pkg/connector/events.go | 1 + pkg/connector/handlematrix.go | 5 +++++ pkg/msgconv/from-matrix.go | 1 - 6 files changed, 19 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index b6398d42..334a3bdc 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/yuin/goldmark v1.8.6 go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 go.mau.fi/webp v0.3.0 - go.mau.fi/whatsmeow v0.0.0-20261006124319-9399289b022b + go.mau.fi/whatsmeow v0.0.0-20261013142727-4cf8bc8e0aeb golang.org/x/image v0.46.0 golang.org/x/net v0.59.0 golang.org/x/sync v0.23.0 diff --git a/go.sum b/go.sum index 5854082c..e5f286db 100644 --- a/go.sum +++ b/go.sum @@ -71,8 +71,8 @@ go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 h1:PxaO4u8ti7Eef1WKPAINXrcU go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8/go.mod h1:40TDo7/ekSeOjgr8KAmX31Yf4zrOF94j83WQB+u5ZPc= go.mau.fi/webp v0.3.0 h1:gVHQZtz21Ziwj+CDuklbX9mqpsnDIFKxs/BJyV7iZzA= go.mau.fi/webp v0.3.0/go.mod h1:rlZFTev+dYxhvk+XNBP/5GcTt4gXmzAB4DU0aGUYIQo= -go.mau.fi/whatsmeow v0.0.0-20261006124319-9399289b022b h1:sxHC8T2zT69QJmFx6zuB3KuDu+49KFf28bszKtv8vXg= -go.mau.fi/whatsmeow v0.0.0-20261006124319-9399289b022b/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4= +go.mau.fi/whatsmeow v0.0.0-20261013142727-4cf8bc8e0aeb h1:yRvCcaQwqHMpUrV9Cn48QOw/YSsV1BlXCs00BTRz0Ks= +go.mau.fi/whatsmeow v0.0.0-20261013142727-4cf8bc8e0aeb/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index d26aeaef..61b9e740 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -276,6 +276,8 @@ func setTopicID(id, topic string) bridgev2.ExtraUpdater[*bridgev2.Portal] { func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupInfo) *bridgev2.ChatInfo { sendEventPL := defaultPL + // Community announcement groups allow non-admins to send threaded replies. + // Power levels can't represent that, but the power level is checked separately when handling messages. if info.IsAnnounce && !info.IsDefaultSubGroup { sendEventPL = adminPL } @@ -306,6 +308,7 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn event.StateRoomName: metaChangePL, event.StateRoomAvatar: metaChangePL, event.StateTopic: metaChangePL, + event.EventEncrypted: defaultPL, // Need to always allow encrypted events for poll responses event.EventReaction: defaultPL, event.EventRedaction: defaultPL, event.EventUnstablePollResponse: defaultPL, @@ -572,12 +575,13 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne StateDefault: ptr.Ptr(nobodyPL), Ban: ptr.Ptr(nobodyPL), Events: map[event.Type]int{ - event.StateRoomName: adminPL, - event.StateRoomAvatar: adminPL, - event.StateTopic: adminPL, - event.EventReaction: defaultPL, - event.EventRedaction: defaultPL, - event.EventUnstablePollResponse: defaultPL, + event.StateRoomName: adminPL, + event.StateRoomAvatar: adminPL, + event.StateTopic: adminPL, + event.EventReaction: defaultPL, + event.EventRedaction: defaultPL, + // TODO support poll votes in channels + //event.EventUnstablePollResponse: defaultPL, }, }, }, diff --git a/pkg/connector/events.go b/pkg/connector/events.go index 743492d1..481d0d73 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -539,6 +539,7 @@ func (evt *WAMediaRetry) ConvertEdit(ctx context.Context, portal *bridgev2.Porta log.Debug().Msg("Got error response in media retry notification, but response also contains a new download URL - trying to download") } } + log.Trace().Any("retry_content", retryData).Msg("Decrypted successful media retry response") err = evt.wa.mediaRetryLock.Acquire(ctx, 1) if err != nil { return nil, fmt.Errorf("failed to acquire media retry lock: %w", err) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index 933c43a0..fc5760eb 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -106,6 +106,7 @@ func (wa *WhatsAppClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2 var ErrBroadcastSendDisabled = bridgev2.WrapErrorInStatus(errors.New("sending status messages is disabled")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) var ErrBroadcastReactionUnsupported = bridgev2.WrapErrorInStatus(errors.New("reacting to status messages is not currently supported")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) +var ErrNotCommunityAdmin = bridgev2.WrapErrorInStatus(errors.New("only community admins can send announcements")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusNoPermission) func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage, waMsg *waE2E.Message, req *whatsmeow.SendRequestExtra) (*bridgev2.MatrixMessageResponse, error) { if req == nil { @@ -116,6 +117,7 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg } else { req.ID = wa.Client.GenerateMessageID() } + req.CheckPermissions = true chatJID, err := waid.ParsePortalID(msg.Portal.ID) if err != nil { @@ -134,6 +136,9 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg zerolog.Ctx(ctx).Trace().Any("payload", waMsg).Msg("Outgoing message payload") resp, err := wa.Client.SendMessage(ctx, chatJID, waMsg, *req) if err != nil { + if errors.Is(err, whatsmeow.ErrNoPermissionInGroup) { + return nil, ErrNotCommunityAdmin + } return nil, err } var pickedMessageID networkid.MessageID diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 833813f7..192b8a6d 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -193,7 +193,6 @@ func (mc *MessageConverter) ToWhatsApp( DeprecatedLIDSession: ptr.Ptr(false), } } else { - // TODO check permissions? message.MessageContextInfo = &waE2E.MessageContextInfo{ MessageSecret: random.Bytes(32), } From abf1940af85952912487a87671c82e895d8f574a Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 14 Oct 2026 22:39:42 +0100 Subject: [PATCH 05/56] backfill: skip fake anchor messages in on-demand backwards backfill (#974) Co-authored-by: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- pkg/connector/backfill.go | 16 +++++++++++++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 334a3bdc..730d9683 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( golang.org/x/sync v0.23.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.31.1-0.20261005174338-57e1ea5dd74d + maunium.net/go/mautrix v0.31.1-0.20261014100333-d27bb75b1649 ) require ( diff --git a/go.sum b/go.sum index e5f286db..fcbdc22f 100644 --- a/go.sum +++ b/go.sum @@ -103,5 +103,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/mautrix v0.31.1-0.20261005174338-57e1ea5dd74d h1:sOdF5AF9MiU2mudfMBgQC79FJrhBBDbUkxQCi0T2uHI= -maunium.net/go/mautrix v0.31.1-0.20261005174338-57e1ea5dd74d/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk= +maunium.net/go/mautrix v0.31.1-0.20261014100333-d27bb75b1649 h1:oL4IWgkubcKHVyvTr38FzkQ24EeDZuKTpb1ZSxLnKzU= +maunium.net/go/mautrix v0.31.1-0.20261014100333-d27bb75b1649/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk= diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 85f4499b..e582ad1e 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -706,9 +706,19 @@ func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID if params.AnchorMessage == nil { return nil, fmt.Errorf("anchor message is required to fetch messages from phone") } - parsed, err := waid.ParseMessageID(params.AnchorMessage.ID) + anchor := params.AnchorMessage + parsed, err := waid.ParseMessageID(anchor.ID) if err != nil { - return nil, fmt.Errorf("failed to parse anchor message ID: %w", err) + anchor, err = wa.Main.Bridge.DB.Message.GetFirstPortalMessageExcludingIDPattern(ctx, params.Portal.PortalKey, "fake:%") + if err != nil { + return nil, fmt.Errorf("failed to get first non-fake portal message: %w", err) + } else if anchor == nil { + return &bridgev2.FetchMessagesResponse{}, nil + } + parsed, err = waid.ParseMessageID(anchor.ID) + if err != nil { + return nil, fmt.Errorf("failed to parse anchor message ID: %w", err) + } } msgID := wa.Client.GenerateMessageID() @@ -720,7 +730,7 @@ func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID IsGroup: parsed.Chat.Server == types.GroupServer, }, ID: parsed.ID, - Timestamp: params.AnchorMessage.Timestamp, + Timestamp: anchor.Timestamp, }, 50) zerolog.Ctx(ctx).Debug(). Str("request_msg_id", msgID). From 09329681068f38b962ee7bddf65a2488a4130df9 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 15 Oct 2026 13:36:27 +0300 Subject: [PATCH 06/56] dependencies: update whatsmeow --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 730d9683..989ec73a 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/yuin/goldmark v1.8.6 go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 go.mau.fi/webp v0.3.0 - go.mau.fi/whatsmeow v0.0.0-20261013142727-4cf8bc8e0aeb + go.mau.fi/whatsmeow v0.0.0-20261015103503-3142697f59a2 golang.org/x/image v0.46.0 golang.org/x/net v0.59.0 golang.org/x/sync v0.23.0 diff --git a/go.sum b/go.sum index fcbdc22f..47fad461 100644 --- a/go.sum +++ b/go.sum @@ -71,8 +71,8 @@ go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 h1:PxaO4u8ti7Eef1WKPAINXrcU go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8/go.mod h1:40TDo7/ekSeOjgr8KAmX31Yf4zrOF94j83WQB+u5ZPc= go.mau.fi/webp v0.3.0 h1:gVHQZtz21Ziwj+CDuklbX9mqpsnDIFKxs/BJyV7iZzA= go.mau.fi/webp v0.3.0/go.mod h1:rlZFTev+dYxhvk+XNBP/5GcTt4gXmzAB4DU0aGUYIQo= -go.mau.fi/whatsmeow v0.0.0-20261013142727-4cf8bc8e0aeb h1:yRvCcaQwqHMpUrV9Cn48QOw/YSsV1BlXCs00BTRz0Ks= -go.mau.fi/whatsmeow v0.0.0-20261013142727-4cf8bc8e0aeb/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4= +go.mau.fi/whatsmeow v0.0.0-20261015103503-3142697f59a2 h1:SJbKhrV30ZDjQA7dpeqm4565LTIQs5AByuMmy8gbLnk= +go.mau.fi/whatsmeow v0.0.0-20261015103503-3142697f59a2/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= From 1f7131c2eef8c40a32d0e39f33398b29e2675aef Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Fri, 16 Oct 2026 11:05:18 +0000 Subject: [PATCH 07/56] mediarequest: send pending requests at the first scheduled time (#981) --- pkg/connector/mediarequest.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/connector/mediarequest.go b/pkg/connector/mediarequest.go index 38061967..6a30cf6f 100644 --- a/pkg/connector/mediarequest.go +++ b/pkg/connector/mediarequest.go @@ -88,6 +88,7 @@ func (wa *WhatsAppClient) mediaRequestLoop(ctx context.Context) { return case <-time.After(startIn): } + wa.sendMediaRequests(ctx) ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() for { From 1067291a89f4949887b328104005abe4f7163ddf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 16 Oct 2026 18:03:24 +0300 Subject: [PATCH 08/56] Bump version to v26.10 --- CHANGELOG.md | 25 +++++++++++++++++ cmd/mautrix-whatsapp/main.go | 2 +- go.mod | 28 +++++++++---------- go.sum | 52 ++++++++++++++++++------------------ 4 files changed, 66 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5dd1738..3002f927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +# v26.10 + +* Added support for new rich response messages used by the Muse AI bot. +* Added support for bridging custom profile set for Muse. +* Added automatic one-time resync of keys needed to decrypt Muse messages. +* Added support for blocking and unblocking users on WhatsApp. +* Added support for contact array messages (thanks to [@voska] in [#961]). +* Added partial support for filling quoted message when sending replies to + WhatsApp. +* Changed undecryptable message resends to only bridge as edits and never as new + messages, to avoid races if the message was deleted before the resend. +* Changed login to wait for OTKs to be successfully uploaded, as the login will + fail if the bridge is stopped before that point. +* Changed chat info syncing to not bridge mute status if app state sync is + failing. +* Changed power levels to allow poll votes in read-only groups even if the room + is encrypted. +* Fixed backfilled media retries not getting sent if configured for local time. +* Fixed "delete for me" from WhatsApp not being bridged correctly in DMs. +* Fixed very long WhatsApp messages not being bridged. +* Fixed document message caption edits not being handled correctly. + +[@voska]: https://github.com/voska +[#961]: https://github.com/mautrix/whatsapp/pull/961 + # v26.09 * Bumped minimum Go version to 1.26. diff --git a/cmd/mautrix-whatsapp/main.go b/cmd/mautrix-whatsapp/main.go index a1d85c3c..ded185c6 100644 --- a/cmd/mautrix-whatsapp/main.go +++ b/cmd/mautrix-whatsapp/main.go @@ -18,7 +18,7 @@ var m = mxmain.BridgeMain{ Name: "mautrix-whatsapp", URL: "https://github.com/mautrix/whatsapp", Description: "A Matrix-WhatsApp puppeting bridge.", - Version: "26.09", + Version: "26.10", SemCalVer: true, Connector: &connector.WhatsAppConnector{}, } diff --git a/go.mod b/go.mod index 989ec73a..0c70c3dc 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module go.mau.fi/mautrix-whatsapp go 1.26.0 -toolchain go1.27.1 +toolchain go1.27.2 tool go.mau.fi/util/cmd/maubuild @@ -10,17 +10,17 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.12.3 github.com/rs/zerolog v1.35.1 - github.com/tidwall/gjson v1.19.0 + github.com/tidwall/gjson v1.20.0 github.com/yuin/goldmark v1.8.6 - go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 + go.mau.fi/util v0.11.0 go.mau.fi/webp v0.3.0 - go.mau.fi/whatsmeow v0.0.0-20261015103503-3142697f59a2 - golang.org/x/image v0.46.0 - golang.org/x/net v0.59.0 - golang.org/x/sync v0.23.0 + go.mau.fi/whatsmeow v0.0.0-20261016130820-0b0a1cdac3e2 + golang.org/x/image v0.47.0 + golang.org/x/net v0.61.0 + golang.org/x/sync v0.24.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.31.1-0.20261014100333-d27bb75b1649 + maunium.net/go/mautrix v0.32.0 ) require ( @@ -33,7 +33,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.52 // indirect - github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect + github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect @@ -43,11 +43,11 @@ require ( github.com/vektah/gqlparser/v2 v2.5.27 // indirect go.mau.fi/libsignal v0.2.2 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/mod v0.41.0 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect + golang.org/x/mod v0.42.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect diff --git a/go.sum b/go.sum index 47fad461..576ec249 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= -github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E= -github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E= +github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -51,8 +51,8 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= -github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto= +github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= @@ -67,31 +67,31 @@ github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= -go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 h1:PxaO4u8ti7Eef1WKPAINXrcUkqKshE+n239UtbbJOn4= -go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8/go.mod h1:40TDo7/ekSeOjgr8KAmX31Yf4zrOF94j83WQB+u5ZPc= +go.mau.fi/util v0.11.0 h1:JNEpkVRxfTdJUC0DIGKiRlLKzl4G/qLX/eVzoP7QuwY= +go.mau.fi/util v0.11.0/go.mod h1:04AEFVsc4zNdIkDC1gkdqbg/nZFolHY50pyXh2DSy7s= go.mau.fi/webp v0.3.0 h1:gVHQZtz21Ziwj+CDuklbX9mqpsnDIFKxs/BJyV7iZzA= go.mau.fi/webp v0.3.0/go.mod h1:rlZFTev+dYxhvk+XNBP/5GcTt4gXmzAB4DU0aGUYIQo= -go.mau.fi/whatsmeow v0.0.0-20261015103503-3142697f59a2 h1:SJbKhrV30ZDjQA7dpeqm4565LTIQs5AByuMmy8gbLnk= -go.mau.fi/whatsmeow v0.0.0-20261015103503-3142697f59a2/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4= +go.mau.fi/whatsmeow v0.0.0-20261016130820-0b0a1cdac3e2 h1:+EhsWM5VeE5Vu3KRlk3PmYAY0lEYkbhI5W4mTmM9X/Q= +go.mau.fi/whatsmeow v0.0.0-20261016130820-0b0a1cdac3e2/go.mod h1:iY4lm61nTLWsNiIE1EmHUZwhKGBeoV4dhnVE2uMtNoQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= -golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= -golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= +golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -103,5 +103,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/mautrix v0.31.1-0.20261014100333-d27bb75b1649 h1:oL4IWgkubcKHVyvTr38FzkQ24EeDZuKTpb1ZSxLnKzU= -maunium.net/go/mautrix v0.31.1-0.20261014100333-d27bb75b1649/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk= +maunium.net/go/mautrix v0.32.0 h1:xuAZzzyWce0pFkBhV/MCLFiJdVs2rFkY3Eep7hNng4I= +maunium.net/go/mautrix v0.32.0/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= From c8e38f1dc5093c7d30318719a60be0a8aeb00446 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Fri, 16 Oct 2026 12:39:40 -0400 Subject: [PATCH 09/56] handlewhatsapp: ignore album headers without a reply (#982) --- pkg/connector/wamsgtype.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/connector/wamsgtype.go b/pkg/connector/wamsgtype.go index efdf679d..2b1458b7 100644 --- a/pkg/connector/wamsgtype.go +++ b/pkg/connector/wamsgtype.go @@ -142,7 +142,10 @@ func getMessageType(waMsg *waE2E.Message) string { case waMsg.StickerPackMessage != nil: return "sticker pack" case waMsg.AlbumMessage != nil: - return "album" // or maybe these should be ignored? + if waMsg.GetAlbumMessage().GetContextInfo().GetStanzaID() == "" { + return "ignore" + } + return "album" case waMsg.SendPaymentMessage != nil, waMsg.RequestPaymentMessage != nil, waMsg.DeclinePaymentRequestMessage != nil, waMsg.CancelPaymentRequestMessage != nil, waMsg.PaymentInviteMessage != nil: From 46afb8f781c616514962a4478240844a44c2f1d3 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Fri, 16 Oct 2026 19:35:52 -0400 Subject: [PATCH 10/56] backfill: don't repeat on-demand history sync requests while one is in flight (#980) --- pkg/connector/backfill.go | 40 +++++++++++++++++++++++++++++++++++++++ pkg/connector/client.go | 5 +++++ 2 files changed, 45 insertions(+) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index e582ad1e..f1fc56e9 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -147,6 +147,7 @@ func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context, return } if blob.GetSyncType() == waHistorySync.HistorySync_ON_DEMAND { + wa.finishOnDemandHistorySyncRequest(evt.GetPeerDataRequestSessionID()) wa.handleOnDemandHistorySync(ctx, blob) if err = wa.Main.DB.HSNotif.Delete(ctx, rowid); err != nil { log.Err(err).Msg("Failed to delete queued on-demand history sync notification") @@ -722,6 +723,12 @@ func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID } msgID := wa.Client.GenerateMessageID() + if !wa.startOnDemandHistorySyncRequest(ctx, portalJID, msgID) { + return &bridgev2.FetchMessagesResponse{ + HasMore: true, + Pending: true, + }, nil + } reqData := wa.Client.BuildHistorySyncRequest(&types.MessageInfo{ MessageSource: types.MessageSource{ Chat: portalJID, @@ -743,6 +750,9 @@ func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID Peer: true, }) if err != nil { + wa.onDemandHistorySyncLock.Lock() + delete(wa.onDemandHistorySyncRequests, portalJID) + wa.onDemandHistorySyncLock.Unlock() return nil, fmt.Errorf("failed to send history sync request: %w", err) } return &bridgev2.FetchMessagesResponse{ @@ -751,6 +761,36 @@ func (wa *WhatsAppClient) fetchMessagesFromPhone(ctx context.Context, portalJID }, nil } +type onDemandHistorySyncRequest struct { + ID types.MessageID + SentAt time.Time +} + +func (wa *WhatsAppClient) startOnDemandHistorySyncRequest(ctx context.Context, portalJID types.JID, msgID types.MessageID) bool { + wa.onDemandHistorySyncLock.Lock() + defer wa.onDemandHistorySyncLock.Unlock() + if req, inFlight := wa.onDemandHistorySyncRequests[portalJID]; inFlight { + zerolog.Ctx(ctx).Debug(). + Str("in_flight_request_msg_id", req.ID). + Stringer("portal_jid", portalJID). + Time("sent_at", req.SentAt). + Msg("Not sending history sync request, previous request is still in flight") + return false + } + wa.onDemandHistorySyncRequests[portalJID] = onDemandHistorySyncRequest{ID: msgID, SentAt: time.Now()} + return true +} + +func (wa *WhatsAppClient) finishOnDemandHistorySyncRequest(sessionID string) { + wa.onDemandHistorySyncLock.Lock() + defer wa.onDemandHistorySyncLock.Unlock() + for chatJID, req := range wa.onDemandHistorySyncRequests { + if req.ID == sessionID { + delete(wa.onDemandHistorySyncRequests, chatJID) + } + } +} + func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *waHistorySync.HistorySync) { if len(blob.GetConversations()) > 1 { zerolog.Ctx(ctx).Warn(). diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 61fceecf..cd52be28 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -56,6 +56,8 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2. pushNamesSynced: exsync.NewEvent(), appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time), + onDemandHistorySyncRequests: make(map[types.JID]onDemandHistorySyncRequest), + disableNewsletter: store.BaseClientPayload.GetUserAgent().GetPlatform() == waWa6.ClientPayload_UserAgent_MACOS, } login.Client = w @@ -132,6 +134,9 @@ type WhatsAppClient struct { wasaResyncLock sync.Mutex museProfileLock sync.Mutex lastMuseProfileRequest time.Time + + onDemandHistorySyncLock sync.Mutex + onDemandHistorySyncRequests map[types.JID]onDemandHistorySyncRequest } var ( From 054a7dee77b571b7716a3b0d02ef55adaa4c8c22 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Sun, 18 Oct 2026 12:35:50 +0000 Subject: [PATCH 11/56] directmedia: use LID for sender in media IDs (#983) --- pkg/connector/directmedia.go | 14 ++++++++++++++ pkg/waid/id.go | 8 ++++++-- pkg/waid/mediaid.go | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/pkg/connector/directmedia.go b/pkg/connector/directmedia.go index 8ed94cea..b86f7dcf 100644 --- a/pkg/connector/directmedia.go +++ b/pkg/connector/directmedia.go @@ -34,6 +34,7 @@ import ( "go.mau.fi/util/ptr" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waMmsRetry" + "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" @@ -173,6 +174,9 @@ func (wa *WhatsAppConnector) downloadStickerDirectMedia(ctx context.Context, par func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, parsedID *waid.ParsedMediaID, params map[string]string) (mediaproxy.GetMediaResponse, error) { msg, err := wa.Bridge.DB.Message.GetFirstPartByID(ctx, parsedID.UserLogin, parsedID.Message.String()) + if err == nil && msg == nil && parsedID.Message.Chat.Server == types.HiddenUserServer && parsedID.Message.Sender.Server == types.DefaultUserServer { + msg, err = wa.getDirectMediaMessageWithLIDSender(ctx, parsedID) + } if err != nil { return nil, fmt.Errorf("failed to get message: %w", err) } else if msg == nil { @@ -215,6 +219,16 @@ func (wa *WhatsAppConnector) downloadMessageDirectMedia(ctx context.Context, par return wa.makeDirectMediaResponse(ctx, waClient, keys, keys.MimeType, msg.ID, keys, params) } +func (wa *WhatsAppConnector) getDirectMediaMessageWithLIDSender(ctx context.Context, parsedID *waid.ParsedMediaID) (*database.Message, error) { + lid, err := wa.DeviceStore.LIDMap.GetLIDForPN(ctx, parsedID.Message.Sender) + if err != nil { + return nil, fmt.Errorf("failed to get LID for sender: %w", err) + } else if lid.IsEmpty() { + return nil, nil + } + return wa.Bridge.DB.Message.GetFirstPartByID(ctx, parsedID.UserLogin, waid.MakeMessageID(parsedID.Message.Chat, lid, parsedID.Message.ID)) +} + func (wa *WhatsAppConnector) makeDirectMediaResponse( ctx context.Context, waClient *WhatsAppClient, diff --git a/pkg/waid/id.go b/pkg/waid/id.go index 7f8fe5bf..6edbcb81 100644 --- a/pkg/waid/id.go +++ b/pkg/waid/id.go @@ -84,10 +84,14 @@ func MakeMessageID(chat, sender types.JID, id types.MessageID) networkid.Message } func MakeMessageIDWithAltSender(chat, sender, altSender types.JID, id types.MessageID) networkid.MessageID { + return MakeMessageID(chat, pickMessageIDSender(chat, sender, altSender), id) +} + +func pickMessageIDSender(chat, sender, altSender types.JID) types.JID { if chat.Server == types.HiddenUserServer && sender.Server == types.DefaultUserServer && altSender.Server == types.HiddenUserServer { - sender = altSender + return altSender } - return MakeMessageID(chat, sender, id) + return sender } func MakeFakeMessageID(chat, sender types.JID, data string) networkid.MessageID { diff --git a/pkg/waid/mediaid.go b/pkg/waid/mediaid.go index a0ac2d90..913a23bb 100644 --- a/pkg/waid/mediaid.go +++ b/pkg/waid/mediaid.go @@ -39,7 +39,7 @@ const ( func MakeMediaID(messageInfo *types.MessageInfo, idOverride types.MessageID, receiver networkid.UserLoginID, version []byte) networkid.MediaID { compactChat := compactJID(messageInfo.Chat.ToNonAD()) - compactSender := compactJID(messageInfo.Sender.ToNonAD()) + compactSender := compactJID(pickMessageIDSender(messageInfo.Chat, messageInfo.Sender, messageInfo.SenderAlt).ToNonAD()) receiverID := compactJID(ParseUserLoginID(receiver, 0)) var compactID []byte if idOverride != "" { From c998fd8858ce767ff5b01de71c9d8a4c6669cab8 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 22 Oct 2026 14:24:34 +0200 Subject: [PATCH 12/56] connector: map WhatsApp errors to message statuses and HTTP errors (#985) --- .bridgelint.yaml | 1 + pkg/connector/errors.go | 107 ++++++++++++++++++++++++++++++++ pkg/connector/handlematrix.go | 54 ++++++++-------- pkg/connector/handlewhatsapp.go | 1 + pkg/connector/login.go | 10 +-- pkg/connector/proxy.go | 15 +++-- pkg/connector/startchat.go | 6 +- pkg/msgconv/matrixpoll.go | 4 +- 8 files changed, 156 insertions(+), 42 deletions(-) create mode 100644 .bridgelint.yaml create mode 100644 pkg/connector/errors.go diff --git a/.bridgelint.yaml b/.bridgelint.yaml new file mode 100644 index 00000000..ea2b48b4 --- /dev/null +++ b/.bridgelint.yaml @@ -0,0 +1 @@ +code_format_allowlist: ["wa-connect-failure-%d"] diff --git a/pkg/connector/errors.go b/pkg/connector/errors.go new file mode 100644 index 00000000..1ee1b205 --- /dev/null +++ b/pkg/connector/errors.go @@ -0,0 +1,107 @@ +// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. +// Copyright (C) 2026 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 connector + +import ( + "context" + "errors" + "fmt" + "net/http" + + "go.mau.fi/whatsmeow" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +// wrapConversionError turns a failure to convert a Matrix event into a message status. Nothing has been +// sent to WhatsApp at that point, so the failure is certain. +func wrapConversionError(err error) error { + if err == nil || errors.As(err, &bridgev2.MessageStatus{}) { + return err + } + return bridgev2.WrapErrorInStatus(err). + WithMessage("failed to convert message for WhatsApp"). + WithIsCertain(true). + WithSendNotice(true) +} + +// wrapWhatsAppError turns an error from a whatsmeow request made while handling a Matrix event into a +// message status. +func wrapWhatsAppError(err error) error { + if err == nil || errors.As(err, &bridgev2.MessageStatus{}) { + return err + } + var iqErr *whatsmeow.IQError + var disconnectErr *whatsmeow.DisconnectedError + switch { + case errors.Is(err, whatsmeow.ErrNotConnected), errors.Is(err, whatsmeow.ErrClientIsNil): + return bridgev2.WrapErrorInStatus(err). + WithMessage("not connected to WhatsApp"). + WithIsCertain(true). + WithSendNotice(true) + case errors.Is(err, whatsmeow.ErrMessageTimedOut), errors.Is(err, whatsmeow.ErrIQTimedOut), + errors.Is(err, context.DeadlineExceeded), errors.As(err, &disconnectErr): + return bridgev2.WrapErrorInStatus(err). + WithMessage("no response from WhatsApp"). + WithIsCertain(false). + WithSendNotice(true) + case errors.Is(err, whatsmeow.ErrIQRateOverLimit): + return bridgev2.WrapErrorInStatus(err). + WithMessage("rate limited by WhatsApp"). + WithErrorReason(event.MessageStatusNetworkError). + WithIsCertain(true). + WithSendNotice(true) + case errors.Is(err, whatsmeow.ErrIQForbidden), errors.Is(err, whatsmeow.ErrIQNotAuthorized): + return bridgev2.WrapErrorInStatus(err). + WithMessage("you don't have permission to do that on WhatsApp"). + WithErrorReason(event.MessageStatusNoPermission). + WithStatus(event.MessageStatusFail). + WithIsCertain(true). + WithSendNotice(true) + case errors.Is(err, whatsmeow.ErrServerReturnedError), errors.As(err, &iqErr): + return bridgev2.WrapErrorInStatus(err). + WithMessage("WhatsApp returned an error"). + WithIsCertain(true). + WithSendNotice(true) + default: + return bridgev2.WrapErrorInStatus(err). + WithMessage("unexpected error from WhatsApp"). + WithSendNotice(true) + } +} + +// wrapWhatsAppRespError turns an error from a whatsmeow request made during login or provisioning into +// an HTTP error, keeping the original error in the chain. Clients retry 429 and 502-504 automatically, +// which isn't wanted for provisioning requests, so those statuses aren't used. +func wrapWhatsAppRespError(err error) error { + var mapped bridgev2.RespError + var disconnectErr *whatsmeow.DisconnectedError + switch { + case errors.Is(err, whatsmeow.ErrIQRateOverLimit): + mapped = bridgev2.RespError(mautrix.MLimitExceeded.WithMessage("Rate limited by WhatsApp. Try again later.").WithStatus(http.StatusBadRequest)) + case errors.Is(err, whatsmeow.ErrNotConnected), errors.Is(err, whatsmeow.ErrClientIsNil), errors.As(err, &disconnectErr): + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Not connected to WhatsApp")) + case errors.Is(err, whatsmeow.ErrIQTimedOut), errors.Is(err, context.DeadlineExceeded): + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("WhatsApp didn't respond in time")) + case errors.Is(err, whatsmeow.ErrIQForbidden), errors.Is(err, whatsmeow.ErrIQNotAuthorized): + mapped = bridgev2.RespError(mautrix.MForbidden.WithMessage("WhatsApp denied the request")) + default: + mapped = bridgev2.RespError(mautrix.MUnknown.WithMessage("Request to WhatsApp failed")) + } + return fmt.Errorf("%w: %w", mapped.WithInternalError(err), err) +} diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index fc5760eb..fec40c3c 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -69,7 +69,7 @@ var ( func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (result *bridgev2.MatrixMessageResponse, retErr error) { waMsg, optionMap, err := wa.Main.MsgConv.PollStartToWhatsApp(ctx, msg.Content, msg.ReplyTo, msg.Portal) if err != nil { - return nil, fmt.Errorf("failed to convert poll vote: %w", err) + return nil, wrapConversionError(fmt.Errorf("failed to convert poll start: %w", err)) } defer wa.mcTrack(msg, time.Now(), &retErr) resp, err := wa.handleConvertedMatrixMessage(ctx, &msg.MatrixMessage, waMsg, nil) @@ -89,7 +89,7 @@ func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridge func (wa *WhatsAppClient) HandleMatrixPollVote(ctx context.Context, msg *bridgev2.MatrixPollVote) (result *bridgev2.MatrixMessageResponse, retErr error) { waMsg, err := wa.Main.MsgConv.PollVoteToWhatsApp(ctx, wa.Client, msg.Content, msg.VoteTo) if err != nil { - return nil, fmt.Errorf("failed to convert poll vote: %w", err) + return nil, wrapConversionError(fmt.Errorf("failed to convert poll vote: %w", err)) } defer wa.mcTrack(msg, time.Now(), &retErr) return wa.handleConvertedMatrixMessage(ctx, &msg.MatrixMessage, waMsg, nil) @@ -98,15 +98,15 @@ func (wa *WhatsAppClient) HandleMatrixPollVote(ctx context.Context, msg *bridgev func (wa *WhatsAppClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (result *bridgev2.MatrixMessageResponse, retErr error) { waMsg, req, err := wa.Main.MsgConv.ToWhatsApp(ctx, wa.Client, msg.Event, msg.Content, msg.ReplyTo, msg.ThreadRoot, msg.Portal) if err != nil { - return nil, fmt.Errorf("failed to convert message: %w", err) + return nil, wrapConversionError(fmt.Errorf("failed to convert message: %w", err)) } defer wa.mcTrack(msg, time.Now(), &retErr) return wa.handleConvertedMatrixMessage(ctx, msg, waMsg, req) } -var ErrBroadcastSendDisabled = bridgev2.WrapErrorInStatus(errors.New("sending status messages is disabled")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) -var ErrBroadcastReactionUnsupported = bridgev2.WrapErrorInStatus(errors.New("reacting to status messages is not currently supported")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) -var ErrNotCommunityAdmin = bridgev2.WrapErrorInStatus(errors.New("only community admins can send announcements")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusNoPermission) +var ErrBroadcastSendDisabled = bridgev2.WrapErrorInStatus(errors.New("sending status messages is disabled")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) +var ErrBroadcastReactionUnsupported = bridgev2.WrapErrorInStatus(errors.New("reacting to status messages is not currently supported")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) +var ErrNotCommunityAdmin = bridgev2.WrapErrorInStatus(errors.New("only community admins can send announcements")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusNoPermission).WithStatus(event.MessageStatusFail) func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage, waMsg *waE2E.Message, req *whatsmeow.SendRequestExtra) (*bridgev2.MatrixMessageResponse, error) { if req == nil { @@ -139,7 +139,7 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg if errors.Is(err, whatsmeow.ErrNoPermissionInGroup) { return nil, ErrNotCommunityAdmin } - return nil, err + return nil, wrapWhatsAppError(err) } var pickedMessageID networkid.MessageID if resp.Sender == wa.GetLID() { @@ -204,7 +204,7 @@ func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev if msg.Portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup { reactionMsg.EncReactionMessage, err = wa.Client.EncryptReaction(ctx, msgconv.MessageIDToInfo(ctx, wa.Client, messageID), reactionMsg.ReactionMessage) if err != nil { - return nil, fmt.Errorf("failed to encrypt reaction: %w", err) + return nil, wrapWhatsAppError(fmt.Errorf("failed to encrypt reaction: %w", err)) } reactionMsg.ReactionMessage = nil req.Meta = &types.MsgMetaInfo{ @@ -218,7 +218,7 @@ func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev Metadata: &waid.ReactionMetadata{ SenderDeviceID: wa.JID.Device, }, - }, err + }, wrapWhatsAppError(err) } func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) (retErr error) { @@ -248,7 +248,7 @@ func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *b defer wa.mcTrack(msg, time.Now(), &retErr) resp, err := wa.Client.SendMessage(ctx, portalJID, reactionMsg, extra) zerolog.Ctx(ctx).Trace().Any("response", resp).Msg("WhatsApp reaction response") - return err + return wrapWhatsAppError(err) } func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) (retErr error) { @@ -273,7 +273,7 @@ func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.M waMsg, _, err := wa.Main.MsgConv.ToWhatsApp(ctx, wa.Client, edit.Event, edit.Content, nil, nil, edit.Portal) if err != nil { - return fmt.Errorf("failed to convert message: %w", err) + return wrapConversionError(fmt.Errorf("failed to convert message: %w", err)) } defer wa.mcTrack(edit, time.Now(), &retErr) @@ -288,7 +288,7 @@ func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.M ID: editID, }) log.Trace().Any("response", resp).Msg("WhatsApp edit response") - return err + return wrapWhatsAppError(err) } func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) (retErr error) { @@ -313,7 +313,7 @@ func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *br resp, err := wa.Client.SendMessage(ctx, portalJID, revokeMessage, extra) log.Trace().Any("response", resp).Msg("WhatsApp delete response") - return err + return wrapWhatsAppError(err) } func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) (retErr error) { @@ -362,7 +362,7 @@ func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt * log.Err(err).Strs("ids", ids).Msg("Failed to mark messages as read") } } - return err + return wrapWhatsAppError(err) } func (wa *WhatsAppClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) (retErr error) { @@ -393,7 +393,7 @@ func (wa *WhatsAppClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2. zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to set presence on typing") } } - return wa.Client.SendChatPresence(ctx, portalJID, chatPresence, mediaPresence) + return wrapWhatsAppError(wa.Client.SendChatPresence(ctx, portalJID, chatPresence, mediaPresence)) } var errUnsupportedDisappearingTimer = bridgev2.WrapErrorInStatus(errors.New("invalid value for disappearing timer")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true) @@ -414,7 +414,7 @@ func (wa *WhatsAppClient) HandleMatrixDisappearingTimer(ctx context.Context, msg settingTS := time.UnixMilli(msg.Event.Timestamp) err = wa.Client.SetDisappearingTimer(ctx, portalJID, msg.Content.Timer.Duration, settingTS) if err != nil { - return false, err + return false, wrapWhatsAppError(err) } msg.Portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt = settingTS.Unix() msg.Portal.Disappear = database.DisappearingSetting{ @@ -449,7 +449,7 @@ func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridg return nil, fmt.Errorf("cannot change channel members") } defer wa.mcTrack(msg, time.Now(), &retErr) - return nil, wa.Client.UnfollowNewsletter(ctx, portalJID) + return nil, wrapWhatsAppError(wa.Client.UnfollowNewsletter(ctx, portalJID)) } changes := make([]types.JID, 1) @@ -480,7 +480,7 @@ func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridg defer wa.mcTrack(msg, time.Now(), &retErr) resp, err := wa.Client.UpdateGroupParticipants(ctx, portalJID, changes, action) if err != nil { - return nil, err + return nil, wrapWhatsAppError(err) } else if len(resp) == 0 { return nil, fmt.Errorf("no response for participant change") } else if resp[0].Error != 0 { @@ -506,7 +506,7 @@ func (wa *WhatsAppClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev defer wa.mcTrack(msg, time.Now(), &retErr) err = wa.Client.SetGroupName(ctx, portalJID, msg.Content.Name) if err != nil { - return false, err + return false, wrapWhatsAppError(err) } msg.Portal.Name = msg.Content.Name @@ -530,7 +530,7 @@ func (wa *WhatsAppClient) HandleMatrixRoomTopic(ctx context.Context, msg *bridge oldID := msg.Portal.Metadata.(*waid.PortalMetadata).TopicID err = wa.Client.SetGroupTopic(ctx, portalJID, oldID, newID, msg.Content.Topic) if err != nil { - return false, err + return false, wrapWhatsAppError(err) } msg.Portal.Topic = msg.Content.Topic @@ -566,7 +566,7 @@ func (wa *WhatsAppClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridg defer wa.mcTrack(msg, time.Now(), &retErr) avatarID, err := wa.Client.SetGroupPhoto(ctx, portalJID, data) if err != nil { - return false, err + return false, wrapWhatsAppError(err) } msg.Portal.AvatarMXC = msg.Content.URL @@ -595,7 +595,7 @@ func convertRoomAvatar(data []byte) ([]byte, error) { if isCorrectSize && imageType == "jpeg" { return data, nil } else if len(data) > 10*1024*1024 || width > 12000 || height > 12000 { - return nil, fmt.Errorf("avatar is too large for re-encoding") + return nil, fmt.Errorf("%w: avatar is too large for re-encoding", bridgev2.ErrMediaTooLarge) } img, _, err := image.Decode(bytes.NewReader(data)) @@ -703,7 +703,7 @@ func (wa *WhatsAppClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridg } if chatJID.Server == types.NewsletterServer { defer wa.mcTrack(msg, time.Now(), &retErr) - return wa.Client.UnfollowNewsletter(ctx, chatJID) + return wrapWhatsAppError(wa.Client.UnfollowNewsletter(ctx, chatJID)) } else if chatJID.Server == types.GroupServer { memberInfo, err := wa.Main.Bridge.Matrix.GetMemberInfo(ctx, msg.Portal.MXID, wa.UserLogin.UserMXID) if err != nil { @@ -712,7 +712,7 @@ func (wa *WhatsAppClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridg err = wa.Client.LeaveGroup(ctx, chatJID) if err != nil { // TODO ignore errors saying you already left the group? - return fmt.Errorf("failed to leave group before deleting chat: %w", err) + return wrapWhatsAppError(fmt.Errorf("failed to leave group before deleting chat: %w", err)) } } } @@ -721,7 +721,7 @@ func (wa *WhatsAppClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridg return err } defer wa.mcTrack(msg, time.Now(), &retErr) - return wa.Client.SendAppState(ctx, appstate.BuildDeleteChat(chatJID, lastTS, lastKey, true)) + return wrapWhatsAppError(wa.Client.SendAppState(ctx, appstate.BuildDeleteChat(chatJID, lastTS, lastKey, true))) } func (wa *WhatsAppClient) HandleMatrixBlockUser(ctx context.Context, msg *bridgev2.MatrixBlockUser) error { @@ -733,7 +733,7 @@ func (wa *WhatsAppClient) HandleMatrixBlockUser(ctx context.Context, msg *bridge return err } if msg.Content.ReportSpam { - return fmt.Errorf("spam reporting is not supported") + return bridgev2.WrapErrorInStatus(errors.New("reporting spam is not supported on WhatsApp")).WithIsCertain(true).WithErrorAsMessage().WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) } action := events.BlocklistChangeActionUnblock if msg.Content.Block { @@ -753,5 +753,5 @@ func (wa *WhatsAppClient) HandleMatrixBlockUser(ctx context.Context, msg *bridge } }() } - return err + return wrapWhatsAppError(err) } diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index 74606abe..11b6f08d 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -259,6 +259,7 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) { wa.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: WATemporaryBan, + UserAction: status.UserActionRestart, Message: evt.String(), }) wa.notifyOfflineSyncWaiter(fmt.Errorf("temporary ban: %s", evt.String())) diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 325bc550..387d780c 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -171,7 +171,7 @@ func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { err := wl.Client.Connect() if err != nil { wl.Log.Err(err).Msg("Failed to connect to WhatsApp for QR login") - return nil, err + return nil, wrapWhatsAppRespError(err) } ctx, cancel := context.WithTimeout(ctx, LoginConnectWait) defer cancel() @@ -202,7 +202,7 @@ func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string) err := wl.Client.Connect() if err != nil { wl.Log.Err(err).Msg("Failed to connect to WhatsApp for phone code login") - return nil, err + return nil, wrapWhatsAppRespError(err) } connectCtx, cancelConnect := context.WithTimeout(ctx, LoginConnectWait) err = wl.WaitForQRs.Wait(connectCtx) @@ -223,7 +223,7 @@ func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string) } else if errors.Is(err, whatsmeow.ErrIQRateOverLimit) { return nil, ErrRateLimitedByWhatsApp } - return nil, err + return nil, wrapWhatsAppRespError(err) } wl.Log.Debug().Msg("Phone code login started") return &bridgev2.LoginStep{ @@ -461,7 +461,7 @@ func (wl *WALogin) SubmitWebAuthnResponse(ctx context.Context, rawResp json.RawM if err != nil { wl.Log.Err(err).Msg("Failed to send WebAuthn response") wl.Cancel() - return nil, err + return nil, wrapWhatsAppRespError(err) } select { case <-ctx.Done(): @@ -480,7 +480,7 @@ func (wl *WALogin) SubmitWebAuthnResponse(ctx context.Context, rawResp json.RawM if err != nil { wl.Log.Err(err).Msg("Failed to send WebAuthn confirmation") wl.Cancel() - return nil, err + return nil, wrapWhatsAppRespError(err) } case <-wl.LoginComplete.GetChan(): } diff --git a/pkg/connector/proxy.go b/pkg/connector/proxy.go index 5ae103db..fe1d7ff5 100644 --- a/pkg/connector/proxy.go +++ b/pkg/connector/proxy.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "time" "github.com/rs/zerolog" "go.mau.fi/whatsmeow" @@ -14,11 +15,13 @@ import ( // TODO move proxy stuff to mautrix-go +var getProxyClient = &http.Client{Timeout: 30 * time.Second} + type respGetProxy struct { ProxyURL string `json:"proxy_url"` } -func (wa *WhatsAppConnector) getProxy(reason string) (string, error) { +func (wa *WhatsAppConnector) getProxy(ctx context.Context, reason string) (string, error) { if wa.Config.GetProxyURL == "" { return wa.Config.Proxy, nil } @@ -29,15 +32,17 @@ func (wa *WhatsAppConnector) getProxy(reason string) (string, error) { q := parsed.Query() q.Set("reason", reason) parsed.RawQuery = q.Encode() - req, err := http.NewRequest(http.MethodGet, parsed.String(), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) if err != nil { return "", fmt.Errorf("failed to prepare request: %w", err) } req.Header.Set("User-Agent", mautrix.DefaultUserAgent) - resp, err := http.DefaultClient.Do(req) + resp, err := getProxyClient.Do(req) if err != nil { return "", fmt.Errorf("failed to send request: %w", err) - } else if resp.StatusCode >= 300 || resp.StatusCode < 200 { + } + defer resp.Body.Close() + if resp.StatusCode >= 300 || resp.StatusCode < 200 { return "", fmt.Errorf("unexpected status code %d", resp.StatusCode) } var respData respGetProxy @@ -56,7 +61,7 @@ func (wa *WhatsAppConnector) updateProxy(ctx context.Context, client *whatsmeow. if isLogin { reason = "login" } - if proxy, err := wa.getProxy(reason); err != nil { + if proxy, err := wa.getProxy(ctx, reason); err != nil { return fmt.Errorf("failed to get proxy address: %w", err) } else if proxy == "" { return nil diff --git a/pkg/connector/startchat.go b/pkg/connector/startchat.go index 18e83263..d4e5f8e8 100644 --- a/pkg/connector/startchat.go +++ b/pkg/connector/startchat.go @@ -88,7 +88,7 @@ func (wa *WhatsAppClient) validateIdentifer(ctx context.Context, number string) } else if entry, ok := isOnWhatsappCache.Get(number); ok && time.Since(entry.ts) < 4*time.Hour { return entry.jid, nil } else if resp, err := wa.Client.IsOnWhatsApp(ctx, []string{number}); err != nil { - return types.EmptyJID, fmt.Errorf("failed to check if number is on WhatsApp: %w", err) + return types.EmptyJID, wrapWhatsAppRespError(fmt.Errorf("failed to check if number is on WhatsApp: %w", err)) } else if len(resp) == 0 { return types.EmptyJID, fmt.Errorf("the server did not respond to the query") } else if !resp[0].IsIn { @@ -128,7 +128,7 @@ func (wa *WhatsAppClient) startChatPNToLID(ctx context.Context, jid types.JID) ( } else if lid.IsEmpty() { resp, err := wa.Client.GetUserInfo(ctx, []types.JID{jid}) if err != nil { - return jid, fmt.Errorf("failed to get user info for phone number: %w", err) + return jid, wrapWhatsAppRespError(fmt.Errorf("failed to get user info for phone number: %w", err)) } else if info, ok := resp[jid]; !ok { return jid, fmt.Errorf("server didn't return user info for phone number") } else if info.LID.IsEmpty() { @@ -292,7 +292,7 @@ func (wa *WhatsAppClient) CreateGroup(ctx context.Context, params *bridgev2.Grou } resp, err := wa.Client.CreateGroup(ctx, req) if err != nil { - return nil, fmt.Errorf("failed to create group: %w", err) + return nil, wrapWhatsAppRespError(fmt.Errorf("failed to create group: %w", err)) } failedParticipants := make(map[networkid.UserID]*bridgev2.CreateChatFailedParticipant) filteredParticipants := resp.Participants[:0] diff --git a/pkg/msgconv/matrixpoll.go b/pkg/msgconv/matrixpoll.go index fab83303..0fe0026d 100644 --- a/pkg/msgconv/matrixpoll.go +++ b/pkg/msgconv/matrixpoll.go @@ -57,8 +57,8 @@ func (mc *MessageConverter) msc1767ToWhatsApp(ctx context.Context, msg event.MSC } var ( - errPollMissingQuestion = bridgev2.WrapErrorInStatus(errors.New("poll message is missing question")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) - errPollDuplicateOption = bridgev2.WrapErrorInStatus(errors.New("poll options must be unique")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported) + errPollMissingQuestion = bridgev2.WrapErrorInStatus(errors.New("poll message is missing question")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) + errPollDuplicateOption = bridgev2.WrapErrorInStatus(errors.New("poll options must be unique")).WithIsCertain(true).WithErrorAsMessage().WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) ) func (mc *MessageConverter) PollStartToWhatsApp( From c8564186f4b81ee8a80edbaa105d10d7ad27d9c0 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Thu, 22 Oct 2026 15:09:03 +0000 Subject: [PATCH 13/56] msgconv/from-whatsapp: cut off long messages after adding mentions (#988) --- pkg/msgconv/from-whatsapp.go | 1 + pkg/msgconv/wa-text.go | 26 ++++++++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/pkg/msgconv/from-whatsapp.go b/pkg/msgconv/from-whatsapp.go index 99d036f7..c537e767 100644 --- a/pkg/msgconv/from-whatsapp.go +++ b/pkg/msgconv/from-whatsapp.go @@ -320,6 +320,7 @@ func (mc *MessageConverter) ToMatrix( } cm.ThreadRoot = ptr.Ptr(waid.MakeMessageID(chat, pcp, commentTarget.GetID())) } + mc.cutOffLongMessage(part.Content) return cm } diff --git a/pkg/msgconv/wa-text.go b/pkg/msgconv/wa-text.go index 7859f37f..1a016334 100644 --- a/pkg/msgconv/wa-text.go +++ b/pkg/msgconv/wa-text.go @@ -21,6 +21,7 @@ import ( "fmt" "html" "regexp" + "slices" "strconv" "strings" @@ -42,16 +43,29 @@ func (mc *MessageConverter) convertTextMessage(ctx context.Context, msg *waE2E.M } contextInfo = msg.GetExtendedTextMessage().GetContextInfo() mc.parseFormatting(part.Content, false, false) - if len(part.Content.FormattedBody) > 20000 { - part.Content.Body = part.Content.Body[:500] + "[...]" - } - if len(part.Content.FormattedBody) > 40000 { - part.Content.FormattedBody = part.Content.FormattedBody[:40000] + "
[long message cut off]" - } part.Content.BeeperLinkPreviews = mc.convertURLPreviewToBeeper(ctx, msg.GetExtendedTextMessage()) return } +func (mc *MessageConverter) cutOffLongMessage(content *event.MessageEventContent) { + if len(content.FormattedBody) <= 20000 && len(content.Body) <= 20000 { + return + } + if content.FormattedBody == "" { + if len(content.Body) > 40000 { + content.Body = content.Body[:40000] + "\n\n[long message cut off]" + } + } else if len(content.Body) > 500 { + content.Body = content.Body[:500] + "[...]" + } + if len(content.FormattedBody) > 40000 { + content.FormattedBody = content.FormattedBody[:40000] + "
[long message cut off]" + } + if content.Mentions != nil { + content.Mentions.UserIDs = slices.DeleteFunc(content.Mentions.UserIDs, mc.Bridge.IsGhostMXID) + } +} + func (mc *MessageConverter) parseFormatting(content *event.MessageEventContent, allowInlineURL, forceHTML bool) { parsedHTML := parseWAFormattingToHTML(content.Body, allowInlineURL) if forceHTML || parsedHTML != event.TextToHTML(content.Body) { From 33b7768ef0d0c781e6e2785f083b9d81adb12822 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Thu, 22 Oct 2026 15:10:37 +0000 Subject: [PATCH 14/56] handlewhatsapp: ignore chat deletes older than the last message (#984) --- pkg/connector/handlewhatsapp.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index 11b6f08d..e7a9b65d 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -714,6 +714,27 @@ func (wa *WhatsAppClient) handleWADeleteChat(ctx context.Context, evt *events.De Type: bridgev2.RemoteEventChatDelete, PortalKey: wa.makeWAPortalKey(chatJID), Timestamp: evt.Timestamp, + PreHandleWithResultFunc: func(ctx context.Context, portal *bridgev2.Portal) *bridgev2.EventHandlingResult { + messageRange := evt.Action.GetMessageRange() + deletedAt := max( + evt.Timestamp.Unix(), + messageRange.GetLastMessageTimestamp(), + messageRange.GetLastSystemMessageTimestamp(), + ) + if deletedAt <= 0 { + return nil + } + lastMessage, err := wa.Main.Bridge.DB.Message.GetLastNInPortal(ctx, portal.PortalKey, 1) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get last message to check if chat delete is outdated") + } else if len(lastMessage) == 1 && lastMessage[0].Timestamp.Unix() > deletedAt { + zerolog.Ctx(ctx).Debug(). + Time("last_message_ts", lastMessage[0].Timestamp). + Msg("Ignoring chat delete that is older than the last message") + return ptr.Ptr(bridgev2.EventHandlingResultIgnored) + } + return nil + }, }, OnlyForMe: true, Children: true, From 83f6cca39bf8676ee805ba02eabdee7bcf5b7d45 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Thu, 22 Oct 2026 18:39:55 +0000 Subject: [PATCH 15/56] msgconv/from-whatsapp: reply to the pinned message in pin notices (#989) --- pkg/msgconv/from-whatsapp.go | 2 +- pkg/msgconv/wa-misc.go | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/msgconv/from-whatsapp.go b/pkg/msgconv/from-whatsapp.go index c537e767..1ff2e021 100644 --- a/pkg/msgconv/from-whatsapp.go +++ b/pkg/msgconv/from-whatsapp.go @@ -187,7 +187,7 @@ func (mc *MessageConverter) ToMatrix( case waMsg.EventMessage != nil: part, contextInfo = mc.convertEventMessage(ctx, waMsg.EventMessage) case waMsg.PinInChatMessage != nil: - part, contextInfo = mc.convertPinInChatMessage(ctx, waMsg.PinInChatMessage) + part, contextInfo = mc.convertPinInChatMessage(ctx, info, waMsg.PinInChatMessage) case waMsg.KeepInChatMessage != nil: part, contextInfo = mc.convertKeepInChatMessage(ctx, waMsg.KeepInChatMessage) case waMsg.RichResponseMessage != nil: diff --git a/pkg/msgconv/wa-misc.go b/pkg/msgconv/wa-misc.go index 275b1326..a0d70978 100644 --- a/pkg/msgconv/wa-misc.go +++ b/pkg/msgconv/wa-misc.go @@ -322,11 +322,19 @@ func (mc *MessageConverter) convertEventMessage(ctx context.Context, msg *waE2E. }, msg.GetContextInfo() } -func (mc *MessageConverter) convertPinInChatMessage(ctx context.Context, msg *waE2E.PinInChatMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { +func (mc *MessageConverter) convertPinInChatMessage(ctx context.Context, info *types.MessageInfo, msg *waE2E.PinInChatMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { body := "Pinned a message" if msg.GetType() == waE2E.PinInChatMessage_UNPIN_FOR_ALL { body = "Unpinned a message" } + var contextInfo *waE2E.ContextInfo + if target, err := waid.ParseMessageID(KeyToMessageID(ctx, getClient(ctx), info.Chat, info.Sender, msg.GetKey())); err == nil { + contextInfo = &waE2E.ContextInfo{ + StanzaID: proto.String(target.ID), + Participant: proto.String(target.Sender.String()), + RemoteJID: proto.String(target.Chat.String()), + } + } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, @@ -334,7 +342,7 @@ func (mc *MessageConverter) convertPinInChatMessage(ctx context.Context, msg *wa MsgType: event.MsgNotice, Body: body, }, - }, nil + }, contextInfo } func (mc *MessageConverter) convertKeepInChatMessage(ctx context.Context, msg *waE2E.KeepInChatMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { From 8f1937b25941872fa1abbff73970ef35e0b8870e Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 06:18:12 -0400 Subject: [PATCH 16/56] msgconv/rich-response: don't include raw data if the message would be too large (#996) --- pkg/msgconv/wa-rich-response.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/msgconv/wa-rich-response.go b/pkg/msgconv/wa-rich-response.go index 41ac9ab9..28a648c3 100644 --- a/pkg/msgconv/wa-rich-response.go +++ b/pkg/msgconv/wa-rich-response.go @@ -66,10 +66,15 @@ func (mc *MessageConverter) convertUnifiedRichResponseMessage(ctx context.Contex Extra: extra, } } + content := format.HTMLToContent(htmlBuf.String()) + if len(data)+len(content.Body)+len(content.FormattedBody) > 40000 { + delete(extra, "fi.mau.whatsapp.rich_message") + delete(extra, "fi.mau.whatsapp.non_json_rich_message") + } extra["fi.mau.whatsapp.rich_response_id"] = rrMsg.ResponseID return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, - Content: new(format.HTMLToContent(htmlBuf.String())), + Content: &content, Extra: extra, } } From 56a19a1d10b3ed17b0409174a9327bde9fefd96b Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 06:18:46 -0400 Subject: [PATCH 17/56] msgconv/from-whatsapp: count mentions when cutting off long messages (#995) --- pkg/msgconv/wa-text.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/msgconv/wa-text.go b/pkg/msgconv/wa-text.go index 1a016334..52874fd8 100644 --- a/pkg/msgconv/wa-text.go +++ b/pkg/msgconv/wa-text.go @@ -48,22 +48,28 @@ func (mc *MessageConverter) convertTextMessage(ctx context.Context, msg *waE2E.M } func (mc *MessageConverter) cutOffLongMessage(content *event.MessageEventContent) { - if len(content.FormattedBody) <= 20000 && len(content.Body) <= 20000 { + size := len(content.Body) + len(content.FormattedBody) + if content.Mentions != nil { + for _, userID := range content.Mentions.UserIDs { + size += len(userID) + } + } + if size <= 40000 { return } + if content.Mentions != nil { + content.Mentions.UserIDs = slices.DeleteFunc(content.Mentions.UserIDs, mc.Bridge.IsGhostMXID) + } if content.FormattedBody == "" { if len(content.Body) > 40000 { content.Body = content.Body[:40000] + "\n\n[long message cut off]" } - } else if len(content.Body) > 500 { + } else if len(content.FormattedBody) > 20000 && len(content.Body) > 500 { content.Body = content.Body[:500] + "[...]" } if len(content.FormattedBody) > 40000 { content.FormattedBody = content.FormattedBody[:40000] + "
[long message cut off]" } - if content.Mentions != nil { - content.Mentions.UserIDs = slices.DeleteFunc(content.Mentions.UserIDs, mc.Bridge.IsGhostMXID) - } } func (mc *MessageConverter) parseFormatting(content *event.MessageEventContent, allowInlineURL, forceHTML bool) { From 1678681af7a849b66ababf66a928d874db5186da Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 06:24:17 -0400 Subject: [PATCH 18/56] chatinfo: don't create rooms for newsletters that no longer exist (#993) --- pkg/connector/backfill.go | 2 +- pkg/connector/chatinfo.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index f1fc56e9..629039df 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -435,7 +435,7 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) { return } wrappedInfo, err := wa.getChatInfo(ctx, conv.ChatJID, conv, true) - if errors.Is(err, whatsmeow.ErrNotInGroup) || errors.Is(err, whatsmeow.ErrGroupNotFound) { + if errors.Is(err, whatsmeow.ErrNotInGroup) || errors.Is(err, whatsmeow.ErrGroupNotFound) || errors.Is(err, ErrNewsletterNotFound) { log.Debug().Err(err).Stringer("chat_jid", conv.ChatJID). Msg("Skipping creating room because the user is not a participant") //err = wa.Main.DB.Message.DeleteAllInChat(ctx, wa.UserLogin.ID, conv.ChatJID) diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index 61b9e740..ce8c7396 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -31,6 +31,7 @@ func (wa *WhatsAppClient) GetChatInfo(ctx context.Context, portal *bridgev2.Port } var ErrBroadcastList = errors.New("broadcast list bridging is currently not supported") +var ErrNewsletterNotFound = errors.New("newsletter doesn't exist") func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID, conv *wadb.Conversation, isNew bool) (wrapped *bridgev2.ChatInfo, err error) { switch portalJID.Server { @@ -56,6 +57,8 @@ func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID, info, err := wa.Client.GetNewsletterInfo(ctx, portalJID) if err != nil { return nil, err + } else if info == nil || info.ID.IsEmpty() { + return nil, ErrNewsletterNotFound } wrapped = wa.wrapNewsletterInfo(ctx, info) default: From 2759bc5ea25237fee4645e9e2c131f014362cfb7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 Oct 2026 14:17:07 +0200 Subject: [PATCH 19/56] msgconv/from-whatsapp: add support for encrypted direct media --- go.mod | 2 +- go.sum | 4 ++-- pkg/msgconv/wa-media.go | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 0c70c3dc..4dc93226 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( golang.org/x/sync v0.24.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.32.0 + maunium.net/go/mautrix v0.32.1-0.20261026114918-8c92262134ae ) require ( diff --git a/go.sum b/go.sum index 576ec249..b6989127 100644 --- a/go.sum +++ b/go.sum @@ -103,5 +103,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/mautrix v0.32.0 h1:xuAZzzyWce0pFkBhV/MCLFiJdVs2rFkY3Eep7hNng4I= -maunium.net/go/mautrix v0.32.0/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= +maunium.net/go/mautrix v0.32.1-0.20261026114918-8c92262134ae h1:4SY5+nh2B968L5NmMGmF0WPRbV9D1yFLP8U+JASuHXg= +maunium.net/go/mautrix v0.32.1-0.20261026114918-8c92262134ae/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= diff --git a/pkg/msgconv/wa-media.go b/pkg/msgconv/wa-media.go index 20a1a142..ff7008b5 100644 --- a/pkg/msgconv/wa-media.go +++ b/pkg/msgconv/wa-media.go @@ -91,7 +91,9 @@ func (mc *MessageConverter) convertMediaMessage( var err error portal := getPortal(ctx) idOverride := getEditTargetID(ctx) - preparedMedia.URL, err = portal.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeMediaID(messageInfo, idOverride, portal.Receiver, getMediaIDVersion(msg))) + preparedMedia.URL, preparedMedia.File, err = portal.Bridge.GenerateContentURI( + ctx, portal.MXID, waid.MakeMediaID(messageInfo, idOverride, portal.Receiver, getMediaIDVersion(msg)), + ) if err != nil { panic(fmt.Errorf("failed to generate content URI: %w", err)) } From 960b4ed000a6d8da7b6a1d3094bc73389b637830 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 26 Oct 2026 14:54:30 +0200 Subject: [PATCH 20/56] client: send unified session on connect Closes #992 --- go.mod | 2 +- go.sum | 4 ++-- pkg/connector/client.go | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4dc93226..c58cd7ce 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/yuin/goldmark v1.8.6 go.mau.fi/util v0.11.0 go.mau.fi/webp v0.3.0 - go.mau.fi/whatsmeow v0.0.0-20261016130820-0b0a1cdac3e2 + go.mau.fi/whatsmeow v0.0.0-20261026125351-1fbae988179a golang.org/x/image v0.47.0 golang.org/x/net v0.61.0 golang.org/x/sync v0.24.0 diff --git a/go.sum b/go.sum index b6989127..df5df11a 100644 --- a/go.sum +++ b/go.sum @@ -71,8 +71,8 @@ go.mau.fi/util v0.11.0 h1:JNEpkVRxfTdJUC0DIGKiRlLKzl4G/qLX/eVzoP7QuwY= go.mau.fi/util v0.11.0/go.mod h1:04AEFVsc4zNdIkDC1gkdqbg/nZFolHY50pyXh2DSy7s= go.mau.fi/webp v0.3.0 h1:gVHQZtz21Ziwj+CDuklbX9mqpsnDIFKxs/BJyV7iZzA= go.mau.fi/webp v0.3.0/go.mod h1:rlZFTev+dYxhvk+XNBP/5GcTt4gXmzAB4DU0aGUYIQo= -go.mau.fi/whatsmeow v0.0.0-20261016130820-0b0a1cdac3e2 h1:+EhsWM5VeE5Vu3KRlk3PmYAY0lEYkbhI5W4mTmM9X/Q= -go.mau.fi/whatsmeow v0.0.0-20261016130820-0b0a1cdac3e2/go.mod h1:iY4lm61nTLWsNiIE1EmHUZwhKGBeoV4dhnVE2uMtNoQ= +go.mau.fi/whatsmeow v0.0.0-20261026125351-1fbae988179a h1:ZyCV/mWfRkutxs5R0K3v+oyTbF4huprSlPZg/LEFQ7k= +go.mau.fi/whatsmeow v0.0.0-20261026125351-1fbae988179a/go.mod h1:iY4lm61nTLWsNiIE1EmHUZwhKGBeoV4dhnVE2uMtNoQ= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= diff --git a/pkg/connector/client.go b/pkg/connector/client.go index cd52be28..41812355 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -84,6 +84,7 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2. w.Client.ManualHistorySyncDownload = true w.Client.SendReportingTokens = true w.Client.AutomaticMessageRerequestFromPhone = true + w.Client.UnifiedSessionInterval = 24 * time.Hour w.Client.GetMessageForRetry = w.trackNotFoundRetry w.Client.PreRetryCallback = w.trackFoundRetry w.Client.BackgroundEventCtx = w.UserLogin.Log.WithContext(wa.Bridge.BackgroundCtx) From 322485c5a983682b0023bb94281a9ce08a29be8a Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 12:14:24 -0400 Subject: [PATCH 21/56] handlewhatsapp: match own phone number in bot chat receipts (#1000) --- pkg/connector/handlewhatsapp.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index e7a9b65d..4ba4f10b 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -514,6 +514,10 @@ func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Recei } else if evt.Chat.Server == types.NewsletterServer { messageSender = evt.Chat } + var messageSenderAlt types.JID + if evt.MessageSender.IsEmpty() && evt.Chat.Server == types.BotServer { + messageSenderAlt = wa.JID + } var chatAlt types.JID if evt.Chat.Server == types.DefaultUserServer { chatLID, _ := wa.GetStore().LIDs.GetLIDForPN(ctx, evt.Chat) @@ -527,6 +531,9 @@ func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Recei if !chatAlt.IsEmpty() { targets = append(targets, waid.MakeMessageID(chatAlt, messageSender, id)) } + if !messageSenderAlt.IsEmpty() { + targets = append(targets, waid.MakeMessageID(evt.Chat, messageSenderAlt, id)) + } } senderLID := evt.Sender if senderLID.Server == types.DefaultUserServer && !evt.SenderAlt.IsEmpty() { From 57e055c3adb94c63849ac35ecc89a5f32ef3de74 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 16:42:19 +0000 Subject: [PATCH 22/56] lidmigrate: move Meta AI phone number portals to the bot JID (#998) --- pkg/connector/backfill.go | 8 ++++++-- pkg/connector/connector.go | 4 ++++ pkg/connector/lidmigrate.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 629039df..0e319e23 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -249,7 +249,9 @@ func (wa *WhatsAppClient) handleWAHistorySync( } else { totalMessageCount += len(conv.GetMessages()) } - if jid.Server == types.DefaultUserServer { + if jid == types.MetaAIJID { + jid = types.NewMetaAIJID + } else if jid.Server == types.DefaultUserServer { lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, jid) if err != nil { log.Err(err).Stringer("pn", jid).Msg("Failed to get LID for phone number in history sync") @@ -803,7 +805,9 @@ func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *w zerolog.Ctx(ctx).Err(err).Str("jid", conv.GetID()).Msg("Failed to parse portal JID") continue } - if portalJID.Server == types.DefaultUserServer { + if portalJID == types.MetaAIJID { + portalJID = types.NewMetaAIJID + } else if portalJID.Server == types.DefaultUserServer { lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, portalJID) if err != nil { zerolog.Ctx(ctx).Err(err).Stringer("lid", portalJID).Msg("Failed to get LID for phone number in on-demand history sync") diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index d7261d3a..fb3cdc8f 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -164,6 +164,10 @@ func (wa *WhatsAppConnector) Start(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to migrate to LID DMs: %w", err) } + err = wa.migrateMetaAIPortals(ctx) + if err != nil { + return fmt.Errorf("failed to migrate Meta AI portals: %w", err) + } go func() { err = wa.syncMismatchingGhosts(wa.Bridge.BackgroundCtx) if err != nil { diff --git a/pkg/connector/lidmigrate.go b/pkg/connector/lidmigrate.go index ec1c700e..befef072 100644 --- a/pkg/connector/lidmigrate.go +++ b/pkg/connector/lidmigrate.go @@ -47,6 +47,8 @@ func (wa *WhatsAppClient) FindAltTargetMessage(ctx context.Context, targetMsg ne if err != nil { return nil, err } + } else if parsed.Chat == types.NewMetaAIJID { + altChat = types.MetaAIJID } if !altSender.IsEmpty() { altSenderID := *parsed @@ -58,6 +60,8 @@ func (wa *WhatsAppClient) FindAltTargetMessage(ctx context.Context, targetMsg ne altChatID.Chat = altChat if altSender.Server == types.DefaultUserServer { altChatID.Sender = altSender + } else if parsed.Sender == types.NewMetaAIJID { + altChatID.Sender = types.MetaAIJID } alts = append(alts, altChatID.String()) } @@ -211,6 +215,30 @@ func (wa *WhatsAppConnector) migrateToLIDDMs(ctx context.Context) error { return nil } +func (wa *WhatsAppConnector) migrateMetaAIPortals(ctx context.Context) error { + if wa.Bridge.Background || wa.Bridge.DB.KV.Get(ctx, "whatsapp_meta_ai_portals_migrated") == "true" { + return nil + } + log := zerolog.Ctx(ctx).With().Str("action", "migrate meta ai portals").Logger() + const findMetaAIPortals = "SELECT id, receiver FROM portal WHERE bridge_id=$1 AND id=$2" + portalKeys, err := scanPortalKey.NewRowIter(wa.Bridge.DB.Query(ctx, findMetaAIPortals, wa.Bridge.ID, waid.MakePortalID(types.MetaAIJID))).AsList() + if err != nil { + return fmt.Errorf("failed to get Meta AI portals: %w", err) + } + for _, key := range portalKeys { + res, err := wa.reIDPhoneDMToLID(ctx, types.MetaAIJID, types.NewMetaAIJID, key.Receiver) + if err != nil { + return fmt.Errorf("failed to re-ID Meta AI portal for %s: %w", key.Receiver, err) + } + log.Info(). + Str("receiver", string(key.Receiver)). + Stringer("result", res). + Msg("Re-ID'd Meta AI phone number portal") + } + wa.Bridge.DB.KV.Set(ctx, "whatsapp_meta_ai_portals_migrated", "true") + return nil +} + func (wa *WhatsAppConnector) syncMismatchingGhosts(ctx context.Context) error { if wa.Bridge.Background || wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_avatars_resynced") == "true" { return nil From 870414b015739dec7087ea4660b33e11b7f0a237 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 17:58:59 +0000 Subject: [PATCH 23/56] commands: add reset-appstate command (#994) --- pkg/connector/commands.go | 52 +++++++++++++++++++++++++++++++++----- pkg/connector/connector.go | 2 +- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/pkg/connector/commands.go b/pkg/connector/commands.go index 037d24e2..120d05ee 100644 --- a/pkg/connector/commands.go +++ b/pkg/connector/commands.go @@ -148,13 +148,7 @@ func fnSync(ce *commands.Event) { case "appstate": names := appstate.AllPatchNames[:] if len(ce.Args) > 1 { - names = exslices.CastFuncFilter(ce.Args[1:], func(name string) (appstate.WAPatchName, bool) { - if !slices.Contains(appstate.AllPatchNames[:], appstate.WAPatchName(name)) { - ce.Reply("Invalid app state name `%s`", name) - return "", false - } - return appstate.WAPatchName(name), true - }) + names = parseAppStateNames(ce, ce.Args[1:]) } for _, name := range names { err := wa.Client.FetchAppState(ce.Ctx, name, true, false) @@ -174,6 +168,50 @@ func fnSync(ce *commands.Event) { } } +func parseAppStateNames(ce *commands.Event, args []string) []appstate.WAPatchName { + return exslices.CastFuncFilter(args, func(name string) (appstate.WAPatchName, bool) { + if !slices.Contains(appstate.AllPatchNames[:], appstate.WAPatchName(name)) { + ce.Reply("Invalid app state name `%s`", name) + return "", false + } + return appstate.WAPatchName(name), true + }) +} + +var cmdResetAppState = &commands.FullHandler{ + Func: fnResetAppState, + Name: "reset-appstate", + Help: commands.HelpMeta{ + Section: commands.HelpSectionAdmin, + Description: "Ask your phone to reset broken app state. This logs out all linked devices.", + Args: "", + }, + RequiresLogin: true, +} + +func fnResetAppState(ce *commands.Event) { + login := ce.User.GetDefaultLogin() + if login == nil { + ce.Reply("Login not found") + return + } + if len(ce.Args) == 0 { + ce.Reply("Usage: `$cmdprefix reset-appstate `\n\nThis logs out all linked devices, including the bridge. You'll have to log in again afterwards.") + return + } + names := parseAppStateNames(ce, ce.Args) + if len(names) != len(ce.Args) { + return + } + wa := login.Client.(*WhatsAppClient) + _, err := wa.Client.SendPeerMessage(ce.Ctx, whatsmeow.BuildFatalAppStateExceptionNotification(names...)) + if err != nil { + ce.Reply("Failed to send app state reset request: %v", err) + return + } + ce.Reply("Sent app state reset request. Your phone should log out all linked devices shortly, after which you can log in again.") +} + var cmdInviteLink = &commands.FullHandler{ Func: fnInviteLink, Name: "invite-link", diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index fb3cdc8f..94c66ebe 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -110,7 +110,7 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) { wa.DB = wadb.New(bridge.ID, bridge.DB.Database, bridge.Log.With().Str("db_section", "whatsapp").Logger()) wa.MsgConv.DB = wa.DB wa.Bridge.Commands.(*commands.Processor).AddHandlers( - cmdAccept, cmdSync, cmdInviteLink, cmdResolveLink, cmdJoin, + cmdAccept, cmdSync, cmdResetAppState, cmdInviteLink, cmdResolveLink, cmdJoin, ) wa.mediaEditCache = make(MediaEditCache) wa.unmigratedDMs = exsync.NewSet[networkid.PortalKey]() From bb09b7584cc7ac28a4a2fdcd17b71fa2af2f6d36 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 27 Oct 2026 10:42:29 +0000 Subject: [PATCH 24/56] msgconv/from-matrix: convert non-h264 videos when sending (#987) --- pkg/msgconv/from-matrix.go | 58 +++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go index 192b8a6d..ba2adc23 100644 --- a/pkg/msgconv/from-matrix.go +++ b/pkg/msgconv/from-matrix.go @@ -27,12 +27,15 @@ import ( "image/color" "image/jpeg" "net/http" + "os" + "path/filepath" "slices" "strconv" "strings" "time" "github.com/rs/zerolog" + "go.mau.fi/util/exmime" "go.mau.fi/util/ffmpeg" "go.mau.fi/util/ptr" "go.mau.fi/util/random" @@ -515,6 +518,56 @@ func (mc *MessageConverter) convertToWebP(img []byte) ([]byte, int, error) { return webpBuffer.Bytes(), size, nil } +func convertUnsupportedVideoCodec(ctx context.Context, data []byte, mime string) ([]byte, string, error) { + if !ffmpeg.Supported() || !ffmpeg.ProbeSupported() { + return data, mime, nil + } + log := zerolog.Ctx(ctx) + tempdir, err := os.MkdirTemp("", "mautrix_whatsapp_video_*") + if err != nil { + log.Warn().Err(err).Msg("Failed to create temp dir for video, sending it as-is") + return data, mime, nil + } + defer os.RemoveAll(tempdir) + inputPath := filepath.Join(tempdir, "input"+exmime.ExtensionFromMimetype(mime)) + err = os.WriteFile(inputPath, data, 0600) + if err != nil { + log.Warn().Err(err).Msg("Failed to write video to temp file, sending it as-is") + return data, mime, nil + } + probe, err := ffmpeg.Probe(ctx, inputPath) + if err != nil { + log.Warn().Err(err).Msg("Failed to probe video, sending it as-is") + return data, mime, nil + } + videoSupported := !slices.ContainsFunc(probe.Streams, func(stream *ffmpeg.Stream) bool { + return stream.CodecType == "video" && stream.Disposition.AttachedPic == 0 && stream.CodecName != "h264" + }) + audioSupported := !slices.ContainsFunc(probe.Streams, func(stream *ffmpeg.Stream) bool { + return stream.CodecType == "audio" && stream.CodecName != "aac" + }) + if videoSupported && audioSupported { + return data, mime, nil + } + videoArgs := []string{"-c:v", "copy"} + if !videoSupported { + videoArgs = []string{ + "-pix_fmt", "yuv420p", "-c:v", "h264", "-b:v", "2M", + "-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'", + } + } + outputPath := filepath.Join(tempdir, "output.mp4") + err = ffmpeg.ConvertPathWithDestination(ctx, inputPath, outputPath, nil, append(videoArgs, "-c:a", "aac", "-movflags", "+faststart"), false) + if err != nil { + return nil, mime, err + } + converted, err := os.ReadFile(outputPath) + if err != nil { + return nil, mime, err + } + return converted, "video/mp4", nil +} + func (mc *MessageConverter) getOriginalBridgedSticker(ctx context.Context, info *event.BridgedSticker) (*types.StickerPackItem, error) { if info == nil || info.Network != StickerSourceID || !strings.HasPrefix(info.PackURL, StickerPackURLPrefix) || info.ID == "" { return nil, nil @@ -609,7 +662,10 @@ func (mc *MessageConverter) reuploadFileToWhatsApp( case event.MsgVideo: switch mime { case "video/mp4", "video/3gpp": - // allowed + data, mime, err = convertUnsupportedVideoCodec(ctx, data, mime) + if err != nil { + return nil, nil, mime, fmt.Errorf("%w (%s to h264): %w", bridgev2.ErrMediaConvertFailed, mime, err) + } case "video/webm", "video/quicktime": sourceFormat := "webm" if mime == "video/quicktime" { From 76c59bd05db9c53dfd4d9181a73c605f52091976 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 27 Oct 2026 10:42:50 +0000 Subject: [PATCH 25/56] backfill: mark history sync messages read up to the unread count (#1001) --- go.mod | 2 +- go.sum | 4 ++-- pkg/connector/backfill.go | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c58cd7ce..fc190652 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( golang.org/x/sync v0.24.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.32.1-0.20261026114918-8c92262134ae + maunium.net/go/mautrix v0.32.1-0.20261026145030-920bc073ba8d ) require ( diff --git a/go.sum b/go.sum index df5df11a..2775f5f4 100644 --- a/go.sum +++ b/go.sum @@ -103,5 +103,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/mautrix v0.32.1-0.20261026114918-8c92262134ae h1:4SY5+nh2B968L5NmMGmF0WPRbV9D1yFLP8U+JASuHXg= -maunium.net/go/mautrix v0.32.1-0.20261026114918-8c92262134ae/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= +maunium.net/go/mautrix v0.32.1-0.20261026145030-920bc073ba8d h1:DsMn0i7cBKcQZ0AVxJm9hfWAu6xJdjRrf457EtXxyNY= +maunium.net/go/mautrix v0.32.1-0.20261026145030-920bc073ba8d/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 0e319e23..54edbaa2 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -595,9 +595,28 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet resp.HasMore = hasMore resp.Forward = params.Forward resp.MarkRead = markRead + if params.Forward && !markRead && conv != nil && !ptr.Val(conv.MarkedAsUnread) { + resp.ReadUpTo = historySyncReadUpTo(resp.Messages, int(ptr.Val(conv.UnreadCount))) + } return resp, nil } +func historySyncReadUpTo(messages []*bridgev2.BackfillMessage, unreadCount int) time.Time { + if unreadCount <= 0 { + return time.Time{} + } + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Sender.IsFromMe { + continue + } + unreadCount-- + if unreadCount == 0 { + return messages[i].Timestamp.Add(-time.Nanosecond) + } + } + return time.Time{} +} + func (wa *WhatsAppClient) deleteHistorySyncMessages(ctx context.Context, portalJID types.JID, newestTS, oldestTS uint64) { var err error var rows int64 From 021504afb01a5f117dd3ffa368f5a3afe8b19f51 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Tue, 27 Oct 2026 12:28:53 +0000 Subject: [PATCH 26/56] msgconv/wa-media: fix sticker dimension scaling for small non-square stickers (#991) --- pkg/msgconv/wa-media.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/msgconv/wa-media.go b/pkg/msgconv/wa-media.go index ff7008b5..30c6b30c 100644 --- a/pkg/msgconv/wa-media.go +++ b/pkg/msgconv/wa-media.go @@ -255,10 +255,10 @@ func fixStickerDimensions(info *event.FileInfo) { info.Width = WhatsAppStickerSize info.Height = WhatsAppStickerSize } else if info.Width > info.Height { - info.Height /= info.Width / WhatsAppStickerSize + info.Height = info.Height * WhatsAppStickerSize / info.Width info.Width = WhatsAppStickerSize } else { - info.Width /= info.Height / WhatsAppStickerSize + info.Width = info.Width * WhatsAppStickerSize / info.Height info.Height = WhatsAppStickerSize } } From 51b5c5e5fb764435f278883adf63a6dc25842e0f Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Tue, 27 Oct 2026 16:45:29 +0000 Subject: [PATCH 27/56] msgconv/from-whatsapp: bridge live location start as m.location (#1002) --- pkg/msgconv/wa-location.go | 101 +++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 33 deletions(-) diff --git a/pkg/msgconv/wa-location.go b/pkg/msgconv/wa-location.go index 4a1e043f..52f3f131 100644 --- a/pkg/msgconv/wa-location.go +++ b/pkg/msgconv/wa-location.go @@ -30,6 +30,40 @@ import ( "maunium.net/go/mautrix/event" ) +func formatCoordinates(lat, long float64) string { + latChar := 'N' + if lat < 0 { + latChar = 'S' + } + longChar := 'E' + if long < 0 { + longChar = 'W' + } + return fmt.Sprintf("%.4f° %c %.4f° %c", math.Abs(lat), latChar, math.Abs(long), longChar) +} + +func addLocationThumbnail(ctx context.Context, content *event.MessageEventContent, thumbnail []byte) { + if len(thumbnail) == 0 { + return + } + thumbnailMime := http.DetectContentType(thumbnail) + thumbnailURL, thumbnailFile, err := getIntent(ctx).UploadMedia(ctx, getPortal(ctx).MXID, thumbnail, "thumb.jpeg", thumbnailMime) + if err != nil { + return + } + cfg, _, _ := image.DecodeConfig(bytes.NewReader(thumbnail)) + content.Info = &event.FileInfo{ + ThumbnailInfo: &event.FileInfo{ + Size: len(thumbnail), + Width: cfg.Width, + Height: cfg.Height, + MimeType: thumbnailMime, + }, + ThumbnailURL: thumbnailURL, + ThumbnailFile: thumbnailFile, + } +} + func (mc *MessageConverter) convertLocationMessage(ctx context.Context, msg *waE2E.LocationMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { url := msg.GetURL() if len(url) == 0 { @@ -37,15 +71,7 @@ func (mc *MessageConverter) convertLocationMessage(ctx context.Context, msg *waE } name := msg.GetName() if len(name) == 0 { - latChar := 'N' - if msg.GetDegreesLatitude() < 0 { - latChar = 'S' - } - longChar := 'E' - if msg.GetDegreesLongitude() < 0 { - longChar = 'W' - } - name = fmt.Sprintf("%.4f° %c %.4f° %c", math.Abs(msg.GetDegreesLatitude()), latChar, math.Abs(msg.GetDegreesLongitude()), longChar) + name = formatCoordinates(msg.GetDegreesLatitude(), msg.GetDegreesLongitude()) } content := &event.MessageEventContent{ @@ -55,24 +81,7 @@ func (mc *MessageConverter) convertLocationMessage(ctx context.Context, msg *waE FormattedBody: fmt.Sprintf(`Location: %s
%s`, html.EscapeString(url), html.EscapeString(name), html.EscapeString(msg.GetAddress())), GeoURI: fmt.Sprintf("geo:%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude()), } - - if len(msg.GetJPEGThumbnail()) > 0 { - thumbnailMime := http.DetectContentType(msg.GetJPEGThumbnail()) - thumbnailURL, thumbnailFile, err := getIntent(ctx).UploadMedia(ctx, getPortal(ctx).MXID, msg.GetJPEGThumbnail(), "thumb.jpeg", thumbnailMime) - if err == nil { - cfg, _, _ := image.DecodeConfig(bytes.NewReader(msg.GetJPEGThumbnail())) - content.Info = &event.FileInfo{ - ThumbnailInfo: &event.FileInfo{ - Size: len(msg.GetJPEGThumbnail()), - Width: cfg.Width, - Height: cfg.Height, - MimeType: thumbnailMime, - }, - ThumbnailURL: thumbnailURL, - ThumbnailFile: thumbnailFile, - } - } - } + addLocationThumbnail(ctx, content, msg.GetJPEGThumbnail()) return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, @@ -81,14 +90,40 @@ func (mc *MessageConverter) convertLocationMessage(ctx context.Context, msg *waE } func (mc *MessageConverter) convertLiveLocationMessage(ctx context.Context, msg *waE2E.LiveLocationMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { + if msg.DegreesLatitude == nil || msg.DegreesLongitude == nil { + content := &event.MessageEventContent{ + Body: "Started sharing live location", + MsgType: event.MsgNotice, + } + if len(msg.GetCaption()) > 0 { + content.Body += ": " + msg.GetCaption() + } + content.Body += "\n\nUse the WhatsApp app to see the location." + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: content, + }, msg.GetContextInfo() + } + + lat, long := msg.GetDegreesLatitude(), msg.GetDegreesLongitude() + url := fmt.Sprintf("https://maps.google.com/?q=%.5f,%.5f", lat, long) + name := formatCoordinates(lat, long) + const note = "Live updates are only visible in the WhatsApp app." + body := fmt.Sprintf("Live location: %s\n%s", name, url) + formattedBody := fmt.Sprintf(`Live location: %s`, html.EscapeString(url), html.EscapeString(name)) + if caption := msg.GetCaption(); len(caption) > 0 { + body += "\n" + caption + formattedBody += "
" + html.EscapeString(caption) + } content := &event.MessageEventContent{ - Body: "Started sharing live location", - MsgType: event.MsgNotice, + MsgType: event.MsgLocation, + Body: body + "\n\n" + note, + Format: event.FormatHTML, + FormattedBody: formattedBody + "

" + note, + GeoURI: fmt.Sprintf("geo:%.5f,%.5f", lat, long), } - if len(msg.GetCaption()) > 0 { - content.Body += ": " + msg.GetCaption() - } - content.Body += "\n\nUse the WhatsApp app to see the location." + addLocationThumbnail(ctx, content, msg.GetJPEGThumbnail()) + return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: content, From 88f5fc738ca4c0abc7ec3792131233a72f56f2a2 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Wed, 28 Oct 2026 20:33:06 +0000 Subject: [PATCH 28/56] lidmigrate: remove phone number ghosts that rejoined LID DMs (#990) --- pkg/connector/connector.go | 4 +++ pkg/connector/lidmigrate.go | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 94c66ebe..19caace5 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -173,6 +173,10 @@ func (wa *WhatsAppConnector) Start(ctx context.Context) error { if err != nil { wa.Bridge.Log.Err(err).Msg("Failed to sync mismatching ghosts") } + err = wa.removeRejoinedPNGhosts(wa.Bridge.BackgroundCtx) + if err != nil { + wa.Bridge.Log.Err(err).Msg("Failed to remove rejoined PN ghosts") + } }() return nil } diff --git a/pkg/connector/lidmigrate.go b/pkg/connector/lidmigrate.go index befef072..17b24287 100644 --- a/pkg/connector/lidmigrate.go +++ b/pkg/connector/lidmigrate.go @@ -18,6 +18,7 @@ package connector import ( "context" + "errors" "fmt" "time" @@ -25,9 +26,11 @@ import ( "go.mau.fi/util/dbutil" "go.mau.fi/whatsmeow/types" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) @@ -295,3 +298,72 @@ func (wa *WhatsAppConnector) syncMismatchingGhosts(ctx context.Context) error { wa.Bridge.DB.KV.Set(ctx, "whatsapp_lid_avatars_resynced", "true") return nil } + +func (wa *WhatsAppConnector) removeRejoinedPNGhosts(ctx context.Context) error { + if wa.Bridge.Background || wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_dm_pn_ghosts_removed") == "true" { + return nil + } + log := zerolog.Ctx(ctx).With().Str("action", "remove rejoined pn ghosts").Logger() + const findLIDPortals = "SELECT id, mxid FROM portal WHERE bridge_id=$1 AND room_type='dm' AND id LIKE '%@lid' AND mxid IS NOT NULL" + lidPortals, err := scanLIDPortal.NewRowIter(wa.Bridge.DB.Query(ctx, findLIDPortals, wa.Bridge.ID)).AsList() + if err != nil { + return fmt.Errorf("failed to get LID portals: %w", err) + } + var removed, failed int + for _, portal := range lidPortals { + lid, err := waid.ParsePortalID(portal.ID) + if err != nil || lid.Server != types.HiddenUserServer { + continue + } + pn, err := wa.DeviceStore.LIDMap.GetPNForLID(ctx, lid) + if err != nil { + return fmt.Errorf("failed to get PN for LID portal %s: %w", portal.ID, err) + } else if pn.IsEmpty() { + continue + } + pnUserID := waid.MakeUserID(pn) + member, err := wa.Bridge.Matrix.GetMemberInfo(ctx, portal.MXID, wa.Bridge.Matrix.FormatGhostMXID(pnUserID)) + if err != nil { + log.Warn().Err(err).Stringer("room_id", portal.MXID).Msg("Failed to get PN ghost membership") + failed++ + continue + } else if member == nil || member.Membership != event.MembershipJoin { + continue + } + pnGhost, err := wa.Bridge.GetGhostByID(ctx, pnUserID) + if err != nil { + return fmt.Errorf("failed to get PN ghost for %s: %w", pn, err) + } + _, err = pnGhost.Intent.SendState(ctx, portal.MXID, event.StateMember, pnGhost.Intent.GetMXID().String(), &event.Content{ + Parsed: &event.MemberEventContent{Membership: event.MembershipLeave, Reason: "Migrating to LIDs"}, + Raw: map[string]any{"com.beeper.exclude_from_timeline": true}, + }, time.Time{}) + if err != nil { + log.Err(err).Stringer("room_id", portal.MXID).Stringer("pn", pn).Msg("Failed to remove PN ghost from LID DM") + if !errors.Is(err, mautrix.MForbidden) && !errors.Is(err, mautrix.MNotFound) { + failed++ + } + continue + } + removed++ + } + log.Info(). + Int("removed_ghosts", removed). + Int("failed", failed). + Int("total_lid_portals", len(lidPortals)). + Msg("Finished removing rejoined PN ghosts from LID DMs") + if failed == 0 && ctx.Err() == nil { + wa.Bridge.DB.KV.Set(ctx, "whatsapp_lid_dm_pn_ghosts_removed", "true") + } + return nil +} + +type lidPortal struct { + ID networkid.PortalID + MXID id.RoomID +} + +var scanLIDPortal = dbutil.ConvertRowFn[lidPortal](func(row dbutil.Scannable) (portal lidPortal, err error) { + err = row.Scan(&portal.ID, &portal.MXID) + return +}) From 7a0b4b580875e25169b7afac36d334388424bd4d Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 29 Oct 2026 10:32:04 +0000 Subject: [PATCH 29/56] login: support client WebSocket tunnels (#1004) --- go.mod | 2 +- go.sum | 4 ++-- pkg/connector/login.go | 36 ++++++++++++++++++++++-------------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index fc190652..b059b8b8 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( golang.org/x/sync v0.24.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.32.1-0.20261026145030-920bc073ba8d + maunium.net/go/mautrix v0.32.1-0.20261028161347-0907c3c7611e ) require ( diff --git a/go.sum b/go.sum index 2775f5f4..498f286d 100644 --- a/go.sum +++ b/go.sum @@ -103,5 +103,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/mautrix v0.32.1-0.20261026145030-920bc073ba8d h1:DsMn0i7cBKcQZ0AVxJm9hfWAu6xJdjRrf457EtXxyNY= -maunium.net/go/mautrix v0.32.1-0.20261026145030-920bc073ba8d/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= +maunium.net/go/mautrix v0.32.1-0.20261028161347-0907c3c7611e h1:7F0CBiL2wsWCfZ1gZlAc30fVeLKNMzuIQb9PZ2mPwts= +maunium.net/go/mautrix v0.32.1-0.20261028161347-0907c3c7611e/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 387d780c..c5d4c0af 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -137,6 +137,7 @@ var ( _ bridgev2.LoginProcessDisplayAndWait = (*WALogin)(nil) _ bridgev2.LoginProcessUserInput = (*WALogin)(nil) _ bridgev2.LoginProcessWithOverride = (*WALogin)(nil) + _ bridgev2.LoginProcessWithParams = (*WALogin)(nil) _ bridgev2.LoginProcessWebAuthn = (*WALogin)(nil) ) @@ -144,6 +145,14 @@ const LoginConnectWait = 30 * time.Second const LoginPairPhoneWait = 30 * time.Second func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + return wl.StartWithParams(ctx, bridgev2.LoginStartParams{}) +} + +func (wl *WALogin) StartWithOverride(ctx context.Context, old *bridgev2.UserLogin) (*bridgev2.LoginStep, error) { + return wl.StartWithParams(ctx, bridgev2.LoginStartParams{Override: old}) +} + +func (wl *WALogin) StartWithParams(ctx context.Context, params bridgev2.LoginStartParams) (*bridgev2.LoginStep, error) { wl.Main.firstClientConnectOnce.Do(wl.Main.onFirstClientConnect) device := wl.Main.DeviceStore.NewDevice() wl.Client = whatsmeow.NewClient(device, waLog.Zerolog(wl.Log)) @@ -153,8 +162,20 @@ func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { if err := wl.Main.updateProxy(ctx, wl.Client, true); err != nil { return nil, err } + if params.WebSocketHTTP != nil { + wl.Client.SetPreLoginHTTPClient(&http.Client{Transport: params.WebSocketHTTP}) + } if wl.PhoneCode { + if params.Override != nil { + phoneNumber := fmt.Sprintf("+%s", params.Override.ID) + wl.Log.Debug(). + Str("phone_number", phoneNumber). + Msg("Auto-submitting phone number for relogin") + return wl.SubmitUserInput(ctx, map[string]string{ + "phone_number": phoneNumber, + }) + } return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeUserInput, StepID: LoginStepIDPhoneNumber, @@ -184,20 +205,6 @@ func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { return makeQRStep(wl.QRs[0]), nil } -func (wl *WALogin) StartWithOverride(ctx context.Context, old *bridgev2.UserLogin) (*bridgev2.LoginStep, error) { - step, err := wl.Start(ctx) - if err == nil && step != nil && old != nil && step.StepID == LoginStepIDPhoneNumber { - phoneNumber := fmt.Sprintf("+%s", old.ID) - wl.Log.Debug(). - Str("phone_number", phoneNumber). - Msg("Auto-submitting phone number for relogin") - return wl.SubmitUserInput(ctx, map[string]string{ - "phone_number": phoneNumber, - }) - } - return step, err -} - func (wl *WALogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { err := wl.Client.Connect() if err != nil { @@ -316,6 +323,7 @@ func (wl *WALogin) handleEvent(rawEvt any) { wl.LoginError = ErrUnexpectedDisconnect case *events.ManualLoginReconnect: wl.Received515.Set() + return default: wl.Log.Warn().Type("event_type", evt).Msg("Got unexpected event") return From ab1fae7181a25d12bacf40fdb5f77dcbdef002e2 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Thu, 29 Oct 2026 12:57:18 +0000 Subject: [PATCH 30/56] handlematrix: reject empty group names (#1007) --- pkg/connector/handlematrix.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index fec40c3c..a37d66f1 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -107,6 +107,7 @@ func (wa *WhatsAppClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2 var ErrBroadcastSendDisabled = bridgev2.WrapErrorInStatus(errors.New("sending status messages is disabled")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) var ErrBroadcastReactionUnsupported = bridgev2.WrapErrorInStatus(errors.New("reacting to status messages is not currently supported")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) var ErrNotCommunityAdmin = bridgev2.WrapErrorInStatus(errors.New("only community admins can send announcements")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusNoPermission).WithStatus(event.MessageStatusFail) +var ErrEmptyGroupName = bridgev2.WrapErrorInStatus(errors.New("WhatsApp group names can't be empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage, waMsg *waE2E.Message, req *whatsmeow.SendRequestExtra) (*bridgev2.MatrixMessageResponse, error) { if req == nil { @@ -501,6 +502,8 @@ func (wa *WhatsAppClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev if msg.Portal.RoomType == database.RoomTypeDM { return false, fmt.Errorf("cannot set room name for DM") + } else if msg.Content.Name == "" { + return false, ErrEmptyGroupName } defer wa.mcTrack(msg, time.Now(), &retErr) From 33357ced5ae666d9b8e85a56327f28bfb97a293c Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 29 Oct 2026 13:15:13 +0000 Subject: [PATCH 31/56] directmedia,handlematrix: report WhatsApp send rejections as permanent failures (#1006) Co-authored-by: Claude Opus 5.5 --- pkg/connector/directmedia.go | 14 +++++++++- pkg/connector/errors.go | 49 +++++++++++++++++++++++++++++++++++ pkg/connector/handlematrix.go | 13 ++++++---- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/pkg/connector/directmedia.go b/pkg/connector/directmedia.go index b86f7dcf..03ef3984 100644 --- a/pkg/connector/directmedia.go +++ b/pkg/connector/directmedia.go @@ -164,7 +164,19 @@ func (wa *WhatsAppConnector) downloadStickerDirectMedia(ctx context.Context, par return nil, fmt.Errorf("no WhatsApp client found on login %s", parsedID.UserLogin) } sticker, err := wa.MsgConv.GetCachedSticker(ctx, waClient.Client, parsedID.Sticker.PackID, parsedID.Sticker.FileHash) - if err != nil { + var httpErr whatsmeow.DownloadHTTPError + if errors.As(err, &httpErr) { + switch httpErr.StatusCode { + case http.StatusNotFound, http.StatusGone: + return nil, mautrix.MNotFound.WithMessage("Sticker pack not found") + case http.StatusUnauthorized, http.StatusForbidden: + return nil, mautrix.MForbidden.WithMessage("WhatsApp denied access to the sticker pack") + case http.StatusTooManyRequests: + return nil, mautrix.MLimitExceeded.WithMessage("Rate limited by WhatsApp while fetching the sticker pack").WithCanRetry(true) + default: + return nil, mautrix.MUnknown.WithMessage("WhatsApp returned HTTP %d while fetching the sticker pack", httpErr.StatusCode).WithStatus(http.StatusBadGateway).WithCanRetry(true) + } + } else if err != nil { return nil, err } else if sticker == nil { return nil, mautrix.MNotFound.WithMessage("Sticker not found in pack") diff --git a/pkg/connector/errors.go b/pkg/connector/errors.go index 1ee1b205..38433d6c 100644 --- a/pkg/connector/errors.go +++ b/pkg/connector/errors.go @@ -21,11 +21,16 @@ import ( "errors" "fmt" "net/http" + "strconv" + "strings" + "time" "go.mau.fi/whatsmeow" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/event" + + "go.mau.fi/mautrix-whatsapp/pkg/waid" ) // wrapConversionError turns a failure to convert a Matrix event into a message status. Nothing has been @@ -85,6 +90,50 @@ func wrapWhatsAppError(err error) error { } } +// wrapSendError turns an error from sending a message to WhatsApp into a message status. +func (wa *WhatsAppClient) wrapSendError(err error) error { + switch sendErrorCode(err) { + case 403: + return bridgev2.WrapErrorInStatus(err). + WithMessage("you don't have permission to do that on WhatsApp"). + WithErrorReason(event.MessageStatusNoPermission). + WithStatus(event.MessageStatusFail). + WithIsCertain(true). + WithSendNotice(true) + case 463: + msg := "WhatsApp has temporarily restricted your account from messaging new contacts" + if until := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).ReachoutTimelockUntil; until.After(time.Now()) { + msg = fmt.Sprintf("WhatsApp has restricted your account from messaging new contacts until %s", until.UTC().Format("Jan 2, 2006 at 3:04 PM MST")) + } + return bridgev2.WrapErrorInStatus(err). + WithMessage(msg). + WithErrorReason(event.MessageStatusNoPermission). + WithStatus(event.MessageStatusFail). + WithIsCertain(true). + WithSendNotice(true) + case 421, 479: + return bridgev2.WrapErrorInStatus(err). + WithMessage("WhatsApp rejected the message"). + WithErrorReason(event.MessageStatusNetworkError). + WithStatus(event.MessageStatusFail). + WithIsCertain(true). + WithSendNotice(true) + default: + return wrapWhatsAppError(err) + } +} + +// sendErrorCode returns the code WhatsApp rejected a message with, which whatsmeow only includes in the +// error text. +func sendErrorCode(err error) int { + if !errors.Is(err, whatsmeow.ErrServerReturnedError) { + return 0 + } + _, codeStr, _ := strings.Cut(err.Error(), whatsmeow.ErrServerReturnedError.Error()+" ") + code, _ := strconv.Atoi(codeStr) + return code +} + // wrapWhatsAppRespError turns an error from a whatsmeow request made during login or provisioning into // an HTTP error, keeping the original error in the chain. Clients retry 429 and 502-504 automatically, // which isn't wanted for provisioning requests, so those statuses aren't used. diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index a37d66f1..d51efcd2 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -108,6 +108,7 @@ var ErrBroadcastSendDisabled = bridgev2.WrapErrorInStatus(errors.New("sending st var ErrBroadcastReactionUnsupported = bridgev2.WrapErrorInStatus(errors.New("reacting to status messages is not currently supported")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) var ErrNotCommunityAdmin = bridgev2.WrapErrorInStatus(errors.New("only community admins can send announcements")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusNoPermission).WithStatus(event.MessageStatusFail) var ErrEmptyGroupName = bridgev2.WrapErrorInStatus(errors.New("WhatsApp group names can't be empty")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusUnsupported).WithStatus(event.MessageStatusFail) +var ErrAddBlockedByPrivacy = bridgev2.WrapErrorInStatus(errors.New("this user's privacy settings don't allow adding them to groups")).WithErrorAsMessage().WithIsCertain(true).WithSendNotice(true).WithErrorReason(event.MessageStatusNoPermission).WithStatus(event.MessageStatusFail) func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage, waMsg *waE2E.Message, req *whatsmeow.SendRequestExtra) (*bridgev2.MatrixMessageResponse, error) { if req == nil { @@ -140,7 +141,7 @@ func (wa *WhatsAppClient) handleConvertedMatrixMessage(ctx context.Context, msg if errors.Is(err, whatsmeow.ErrNoPermissionInGroup) { return nil, ErrNotCommunityAdmin } - return nil, wrapWhatsAppError(err) + return nil, wa.wrapSendError(err) } var pickedMessageID networkid.MessageID if resp.Sender == wa.GetLID() { @@ -219,7 +220,7 @@ func (wa *WhatsAppClient) HandleMatrixReaction(ctx context.Context, msg *bridgev Metadata: &waid.ReactionMetadata{ SenderDeviceID: wa.JID.Device, }, - }, wrapWhatsAppError(err) + }, wa.wrapSendError(err) } func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) (retErr error) { @@ -249,7 +250,7 @@ func (wa *WhatsAppClient) HandleMatrixReactionRemove(ctx context.Context, msg *b defer wa.mcTrack(msg, time.Now(), &retErr) resp, err := wa.Client.SendMessage(ctx, portalJID, reactionMsg, extra) zerolog.Ctx(ctx).Trace().Any("response", resp).Msg("WhatsApp reaction response") - return wrapWhatsAppError(err) + return wa.wrapSendError(err) } func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) (retErr error) { @@ -289,7 +290,7 @@ func (wa *WhatsAppClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.M ID: editID, }) log.Trace().Any("response", resp).Msg("WhatsApp edit response") - return wrapWhatsAppError(err) + return wa.wrapSendError(err) } func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) (retErr error) { @@ -314,7 +315,7 @@ func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *br resp, err := wa.Client.SendMessage(ctx, portalJID, revokeMessage, extra) log.Trace().Any("response", resp).Msg("WhatsApp delete response") - return wrapWhatsAppError(err) + return wa.wrapSendError(err) } func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) (retErr error) { @@ -484,6 +485,8 @@ func (wa *WhatsAppClient) HandleMatrixMembership(ctx context.Context, msg *bridg return nil, wrapWhatsAppError(err) } else if len(resp) == 0 { return nil, fmt.Errorf("no response for participant change") + } else if resp[0].Error == 403 && action == whatsmeow.ParticipantChangeAdd { + return nil, ErrAddBlockedByPrivacy } else if resp[0].Error != 0 { return nil, fmt.Errorf("failed to change participant: code %d", resp[0].Error) } From 123ac4f340b248e1b440ae6715a680b519bcc3de Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Thu, 29 Oct 2026 15:35:40 +0000 Subject: [PATCH 32/56] userinfo: don't clear alt ghost avatar when main ghost has no avatar info (#1008) --- pkg/connector/userinfo.go | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index c78b0a8e..b109e82d 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -30,7 +30,7 @@ var ResyncJitterSeconds = 3600 func (wa *WhatsAppClient) EnqueueGhostResync(ghost *bridgev2.Ghost) { lastSync := ghost.Metadata.(*waid.GhostMetadata).LastSync.Time - if lastSync.Add(ResyncMinInterval).After(time.Now()) { + if ghost.AvatarID != "" && lastSync.Add(ResyncMinInterval).After(time.Now()) { return } wa.resyncQueueLock.Lock() @@ -118,12 +118,14 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID var portals []*bridgev2.Portal for jid, item := range queue { var lastSync time.Time + unknownAvatar := false if item.ghost != nil { lastSync = item.ghost.Metadata.(*waid.GhostMetadata).LastSync.Time + unknownAvatar = item.ghost.AvatarID == "" } else if item.portal != nil { lastSync = item.portal.Metadata.(*waid.PortalMetadata).LastSync.Time } - if lastSync.Add(ResyncMinInterval).After(time.Now()) { + if !unknownAvatar && lastSync.Add(ResyncMinInterval).After(time.Now()) { log.Debug(). Stringer("jid", jid). Time("last_sync", lastSync). @@ -165,7 +167,7 @@ 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, info.PictureID != "" && string(ghost.AvatarID) != info.PictureID) + userInfo, err := wa.getUserInfo(ctx, jid, info.PictureID, ghost.AvatarID == "" || (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 @@ -472,16 +474,19 @@ func (wa *WhatsAppClient) syncAltGhostWithInfo(ctx context.Context, jid types.JI } func makeInfoFromGhost(ghost *bridgev2.Ghost) *bridgev2.UserInfo { - return &bridgev2.UserInfo{ - Identifiers: ghost.Identifiers, - Name: &ghost.Name, - Avatar: &bridgev2.Avatar{ - ID: ghost.AvatarID, - Remove: ghost.AvatarID == "" || ghost.AvatarMXC == "", - MXC: ghost.AvatarMXC, - Hash: ghost.AvatarHash, - }, + info := &bridgev2.UserInfo{ + Identifiers: ghost.Identifiers, + Name: &ghost.Name, IsBot: &ghost.IsBot, ExtraProfile: ghost.ExtraProfile, } + if ghost.AvatarID != "" { + info.Avatar = &bridgev2.Avatar{ + ID: ghost.AvatarID, + Remove: ghost.AvatarMXC == "", + MXC: ghost.AvatarMXC, + Hash: ghost.AvatarHash, + } + } + return info } From e8f1d2b3f65d666ccbbd08d515c261f4c2f2e8ed Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Thu, 29 Oct 2026 22:27:40 +0000 Subject: [PATCH 33/56] msgconv/from-whatsapp: use own profile name for self mentions (#1009) --- pkg/msgconv/from-whatsapp.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/msgconv/from-whatsapp.go b/pkg/msgconv/from-whatsapp.go index 1ff2e021..db18f35a 100644 --- a/pkg/msgconv/from-whatsapp.go +++ b/pkg/msgconv/from-whatsapp.go @@ -17,6 +17,7 @@ package msgconv import ( + "cmp" "context" "fmt" "html" @@ -92,7 +93,7 @@ func (mc *MessageConverter) getBasicUserInfo(ctx context.Context, user types.JID portal := getPortal(ctx) login := mc.Bridge.GetCachedUserLoginByID(waid.MakeUserLoginID(pnJID)) if login != nil && (portal.Receiver == "" || portal.Receiver == login.ID) { - return login.UserMXID, ghost.Name, nil + return login.UserMXID, cmp.Or(login.RemoteProfile.Name, ghost.Name), nil } } return ghost.Intent.GetMXID(), ghost.Name, nil From 1f30d5516cd01424b3348c1f4792c60f7fc12931 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 30 Oct 2026 15:52:27 +0200 Subject: [PATCH 34/56] backfill: drop messages in the whatsapp official updates chat --- pkg/connector/backfill.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 54edbaa2..378b58e8 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -246,6 +246,11 @@ func (wa *WhatsAppClient) handleWAHistorySync( } else if jid.Server == types.BroadcastServer { log.Debug().Stringer("chat_jid", jid).Msg("Skipping broadcast list in history sync") continue + } else if jid == types.PSAJID { + // New messages in the official updates chat aren't bridged because they use a different path, + // so drop history as well. This should be removed if the PSA chat gets properly bridged in the future. + log.Debug().Stringer("chat_jid", jid).Msg("Skipping PSA in history sync") + continue } else { totalMessageCount += len(conv.GetMessages()) } From 678d4ebb4fa2ae9796789f7276c6fce0f79982b8 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Fri, 30 Oct 2026 16:09:59 +0200 Subject: [PATCH 35/56] wadb: re-arm backfill for chats with leftover history sync --- pkg/connector/wadb/upgrades/00-latest-schema.sql | 2 +- .../11-rearm-leftover-history-sync-backfill.sql | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 pkg/connector/wadb/upgrades/11-rearm-leftover-history-sync-backfill.sql diff --git a/pkg/connector/wadb/upgrades/00-latest-schema.sql b/pkg/connector/wadb/upgrades/00-latest-schema.sql index 0f1fef94..c4172d09 100644 --- a/pkg/connector/wadb/upgrades/00-latest-schema.sql +++ b/pkg/connector/wadb/upgrades/00-latest-schema.sql @@ -1,4 +1,4 @@ --- v0 -> v10 (compatible with v3+): Latest revision +-- v0 -> v11 (compatible with v3+): Latest revision CREATE TABLE whatsapp_poll_option_id ( bridge_id TEXT NOT NULL, diff --git a/pkg/connector/wadb/upgrades/11-rearm-leftover-history-sync-backfill.sql b/pkg/connector/wadb/upgrades/11-rearm-leftover-history-sync-backfill.sql new file mode 100644 index 00000000..69e8b1e7 --- /dev/null +++ b/pkg/connector/wadb/upgrades/11-rearm-leftover-history-sync-backfill.sql @@ -0,0 +1,10 @@ +-- v11 (compatible with v3+): Re-arm backfill for chats with leftover history sync messages +UPDATE backfill_task +SET is_done=false, queue_done=false, dispatched_at=NULL, completed_at=NULL +WHERE (is_done OR queue_done) AND EXISTS ( + SELECT 1 + FROM whatsapp_history_sync_message msg + WHERE msg.bridge_id=backfill_task.bridge_id + AND msg.user_login_id=backfill_task.user_login_id + AND msg.chat_jid=backfill_task.portal_id +); From ae8dcdec4bc4bf775e8b7185cc414c49ed8b48b2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 30 Oct 2026 16:09:08 +0200 Subject: [PATCH 36/56] backfill: delete all messages when FetchMessages returns no more history --- pkg/connector/backfill.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 378b58e8..7a6fd781 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -593,10 +593,11 @@ func (wa *WhatsAppClient) FetchMessages(ctx context.Context, params bridgev2.Fet } } } - resp, err := wa.convertHistorySyncMessages(ctx, params.Portal, portalJID, messages, true) + resp, err := wa.convertHistorySyncMessages(ctx, params.Portal, portalJID, messages, true, !params.Forward && !hasMore) if err != nil { return nil, fmt.Errorf("failed to convert messages: %w", err) } + // TODO might be better to always set true here and let GetBetween return an empty result to mark as false? resp.HasMore = hasMore resp.Forward = params.Forward resp.MarkRead = markRead @@ -654,6 +655,7 @@ func (wa *WhatsAppClient) convertHistorySyncMessages( portalJID types.JID, messages []*waWeb.WebMessageInfo, explodeOnError bool, + deleteEverything bool, ) (*bridgev2.FetchMessagesResponse, error) { cli := wa.Client if cli == nil { @@ -709,9 +711,11 @@ func (wa *WhatsAppClient) convertHistorySyncMessages( Messages: convertedMessages, Cursor: networkid.PaginationCursor(strconv.FormatUint(oldestTS, 10)), CompleteCallback: func() { - // TODO this only deletes after backfilling. If there's no need for backfill after a relogin, - // the messages will be stuck in the database - wa.deleteHistorySyncMessages(ctx, portalJID, newestTS, oldestTS) + if deleteEverything { + wa.deleteHistorySyncMessages(ctx, portalJID, 1<<62, oldestTS) + } else { + wa.deleteHistorySyncMessages(ctx, portalJID, newestTS, oldestTS) + } if len(mediaRequests) > 0 { go func(ctx context.Context) { for _, req := range mediaRequests { @@ -872,7 +876,7 @@ func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *w Int("message_count", len(messages)). Stringer("end_of_history_type", conv.GetEndOfHistoryTransferType()). Msg("Converting messages to bridge from on-demand history sync") - resp, err := wa.convertHistorySyncMessages(ctx, portal, portalJID, messages, false) + resp, err := wa.convertHistorySyncMessages(ctx, portal, portalJID, messages, false, false) if err != nil { return nil, err } From de02399ff8ec8d84d57955565979ccca07ba8b0a Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Fri, 30 Oct 2026 14:43:44 +0000 Subject: [PATCH 37/56] msgconv/from-whatsapp: show view once notice for media with view once flag (#1012) --- pkg/msgconv/wa-media.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/msgconv/wa-media.go b/pkg/msgconv/wa-media.go index 30c6b30c..839f4c26 100644 --- a/pkg/msgconv/wa-media.go +++ b/pkg/msgconv/wa-media.go @@ -49,6 +49,9 @@ func (mc *MessageConverter) convertMediaMessage( isViewOnce bool, cachedPart *bridgev2.ConvertedMessagePart, ) (part *bridgev2.ConvertedMessagePart, contextInfo *waE2E.ContextInfo) { + if viewOnceMsg, ok := msg.(MediaMessageWithViewOnce); ok && viewOnceMsg.GetViewOnce() { + isViewOnce = true + } if mc.DisableViewOnce && isViewOnce { body := "You received a view once message. For added privacy, you can only open it on the WhatsApp app." if messageInfo.IsFromMe { @@ -232,6 +235,11 @@ type MediaMessageWithCaption interface { GetCaption() string } +type MediaMessageWithViewOnce interface { + MediaMessage + GetViewOnce() bool +} + type MediaMessageWithDimensions interface { MediaMessage GetHeight() uint32 From 5dd621fdf4232cd9ba837d0bcebac72fcb369884 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Fri, 30 Oct 2026 16:29:54 +0000 Subject: [PATCH 38/56] login: fix ADV secret rotation ending QR and pairing code logins (#1010) --- pkg/connector/login.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/connector/login.go b/pkg/connector/login.go index c5d4c0af..ae57ac1a 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -288,6 +288,7 @@ func (wl *WALogin) handleEvent(rawEvt any) { wl.QRs[i] = strings.Replace(code, evt.OldSecret, evt.NewSecret, 1) } wl.ADVRotate.Set() + return case *events.QRScannedWithoutMultidevice: wl.Log.Error().Msg("QR code scanned without multidevice enabled") wl.LoginError = ErrLoginMultideviceNotEnabled @@ -372,9 +373,14 @@ func (wl *WALogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { case <-wl.PasskeyRequest.GetChan(): return wl.makePasskeyStep() case <-wl.ADVRotate.GetChan(): - wl.Log.Debug().Msg("ADV secret was rotated, returning new QR immediately") wl.ADVRotate.Clear() - return makeQRStep(wl.QRs[nextIndex]), nil + if currentIndex < 0 || currentIndex >= len(wl.QRs) { + wl.Log.Debug().Msg("ADV secret was rotated, but no QRs are left to return") + wl.Cancel() + return nil, ErrLoginTimeout + } + wl.Log.Debug().Msg("ADV secret was rotated, returning current QR immediately") + return makeQRStep(wl.QRs[currentIndex]), nil case <-wl.LoginComplete.GetChan(): // continue } From 9f8224606920ae9d3aa5bbfc067e1cfe5d8b7475 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 1 Nov 2026 00:50:14 +0200 Subject: [PATCH 39/56] handlewhatsapp: exclude stream replaced and client outdated from unknown error autoreconnect Closes #1005 --- go.mod | 2 +- go.sum | 4 ++-- pkg/connector/handlewhatsapp.go | 12 ++++++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index b059b8b8..6e9b5033 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( golang.org/x/sync v0.24.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.32.1-0.20261028161347-0907c3c7611e + maunium.net/go/mautrix v0.32.1-0.20261031224934-b69deb427bd7 ) require ( diff --git a/go.sum b/go.sum index 498f286d..12cf8f53 100644 --- a/go.sum +++ b/go.sum @@ -103,5 +103,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/mautrix v0.32.1-0.20261028161347-0907c3c7611e h1:7F0CBiL2wsWCfZ1gZlAc30fVeLKNMzuIQb9PZ2mPwts= -maunium.net/go/mautrix v0.32.1-0.20261028161347-0907c3c7611e/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= +maunium.net/go/mautrix v0.32.1-0.20261031224934-b69deb427bd7 h1:GpLIqeJTpG0/ti7XA370HgjnSWd/pQxXVGb/FRkayOA= +maunium.net/go/mautrix v0.32.1-0.20261031224934-b69deb427bd7/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index 4ba4f10b..a7d4c918 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -237,7 +237,11 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) { }) wa.notifyOfflineSyncWaiter(fmt.Errorf("stream error: %s", message)) case *events.StreamReplaced: - wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: WAStreamReplaced}) + wa.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: WAStreamReplaced, + BlockAutoReconnect: true, + }) wa.notifyOfflineSyncWaiter(fmt.Errorf("stream replaced")) case *events.KeepAliveTimeout: wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WAKeepaliveTimeout}) @@ -253,7 +257,11 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) { wa.notifyOfflineSyncWaiter(fmt.Errorf("connection failure: %s (%s)", evt.Reason, evt.Message)) case *events.ClientOutdated: wa.UserLogin.Log.Error().Msg("Got a client outdated connect failure. The bridge is likely out of date, please update immediately.") - wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: WAClientOutdated}) + wa.UserLogin.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: WAClientOutdated, + BlockAutoReconnect: true, + }) wa.notifyOfflineSyncWaiter(fmt.Errorf("client outdated")) case *events.TemporaryBan: wa.UserLogin.BridgeState.Send(status.BridgeState{ From 3a5d6448a8b4d7af1021381891f61c664084099c Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Sun, 1 Nov 2026 12:58:51 +0000 Subject: [PATCH 40/56] backfill: treat own messages from previous LIDs as own (#1017) --- pkg/connector/backfill.go | 3 +++ pkg/connector/client.go | 25 ++++++++++++++++++++++++- pkg/waid/dbmeta.go | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 7a6fd781..fe251e1d 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -692,6 +692,9 @@ func (wa *WhatsAppClient) convertHistorySyncMessages( if !wa.ensureAltJIDs(ctx, &evt.Info.MessageSource, false) { return nil, fmt.Errorf("failed to ensure alt JIDs for message %s", evt.Info.ID) } + if evt.Info.IsFromMe { + wa.addPreviousOwnLID(ctx, evt.Info.Sender) + } isViewOnce := evt.IsViewOnce || evt.IsViewOnceV2 || evt.IsViewOnceV2Extension converted, mediaReq := wa.convertHistorySyncMessage( ctx, cli, portal, &evt.Info, evt.Message, evt.RawMessage, isViewOnce, msg.Reactions, diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 41812355..678c8d22 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -55,6 +55,7 @@ func (wa *WhatsAppConnector) LoadUserLogin(ctx context.Context, login *bridgev2. mediaRetryLock: semaphore.NewWeighted(wa.Config.HistorySync.MediaRequests.MaxAsyncHandle), pushNamesSynced: exsync.NewEvent(), appStateFullSyncAttempted: make(map[appstate.WAPatchName]time.Time), + previousOwnLIDs: exsync.NewSetWithItems(login.Metadata.(*waid.UserLoginMetadata).PreviousOwnLIDs), onDemandHistorySyncRequests: make(map[types.JID]onDemandHistorySyncRequest), @@ -138,6 +139,9 @@ type WhatsAppClient struct { onDemandHistorySyncLock sync.Mutex onDemandHistorySyncRequests map[types.JID]onDemandHistorySyncRequest + + previousOwnLIDs *exsync.Set[types.JID] + previousOwnLIDsLock sync.Mutex } var ( @@ -206,7 +210,26 @@ func (wa *WhatsAppClient) IsThisUser(_ context.Context, userID networkid.UserID) func (wa *WhatsAppClient) IsOwnJID(jid types.JID) bool { return (jid.Server == types.DefaultUserServer && jid.User == wa.JID.User) || - (jid.Server == types.HiddenUserServer && jid.User == wa.GetLID().User) + (jid.Server == types.HiddenUserServer && jid.User == wa.GetLID().User) || + wa.previousOwnLIDs.Has(jid.ToNonAD()) +} + +func (wa *WhatsAppClient) addPreviousOwnLID(ctx context.Context, jid types.JID) { + if jid.Server != types.HiddenUserServer || wa.IsOwnJID(jid) { + return + } + jid = jid.ToNonAD() + wa.previousOwnLIDsLock.Lock() + defer wa.previousOwnLIDsLock.Unlock() + if !wa.previousOwnLIDs.Add(jid) { + return + } + zerolog.Ctx(ctx).Info().Stringer("lid", jid).Msg("Found previous own LID in history sync") + wa.UserLogin.Metadata.(*waid.UserLoginMetadata).PreviousOwnLIDs = wa.previousOwnLIDs.AsList() + err := wa.UserLogin.Save(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save user login after adding previous own LID") + } } func (wa *WhatsAppClient) GetLID() types.JID { diff --git a/pkg/waid/dbmeta.go b/pkg/waid/dbmeta.go index 15c0a422..b1a47e0e 100644 --- a/pkg/waid/dbmeta.go +++ b/pkg/waid/dbmeta.go @@ -45,6 +45,8 @@ type UserLoginMetadata struct { HistorySyncPortalsNeedCreating bool `json:"history_sync_portals_need_creating,omitzero"` ReachoutTimelockUntil time.Time `json:"reachout_timelock_until,omitzero"` + PreviousOwnLIDs []types.JID `json:"previous_own_lids,omitempty"` + Blocklist *types.Blocklist `json:"blocklist,omitempty"` MData json.RawMessage `json:"mdata,omitempty"` From 07d8e9417b3451f3adb38c48e8047e273c64c6e8 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 2 Nov 2026 11:14:43 +0000 Subject: [PATCH 41/56] client: build whatsmeow HTTP clients from bridge settings (#1016) --- pkg/connector/connector.go | 11 +++++++++- pkg/connector/proxy.go | 45 +++++++++++++++++++++++++------------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 19caace5..80b9d95e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -243,7 +243,16 @@ func (wa *WhatsAppConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ e } func (wa *WhatsAppConnector) ResetHTTPTransport() { - // No-op for now, whatsmeow doesn't use the shared transport config yet + for _, login := range wa.Bridge.GetAllCachedUserLogins() { + client := login.Client.(*WhatsAppClient).Client + if client == nil { + continue + } + err := wa.updateProxy(login.Log.WithContext(wa.Bridge.BackgroundCtx), client, false) + if err != nil { + login.Log.Err(err).Msg("Failed to recreate HTTP clients") + } + } } func (wa *WhatsAppConnector) ResetNetworkConnections() { diff --git a/pkg/connector/proxy.go b/pkg/connector/proxy.go index fe1d7ff5..8bd6d658 100644 --- a/pkg/connector/proxy.go +++ b/pkg/connector/proxy.go @@ -54,23 +54,38 @@ func (wa *WhatsAppConnector) getProxy(ctx context.Context, reason string) (strin } func (wa *WhatsAppConnector) updateProxy(ctx context.Context, client *whatsmeow.Client, isLogin bool) error { - if wa.Config.ProxyOnlyLogin && !isLogin { - return nil + var proxy string + if !wa.Config.ProxyOnlyLogin || isLogin { + reason := "connect" + if isLogin { + reason = "login" + } + var err error + proxy, err = wa.getProxy(ctx, reason) + if err != nil { + return fmt.Errorf("failed to get proxy address: %w", err) + } } - reason := "connect" - if isLogin { - reason = "login" + settings := wa.Bridge.GetHTTPClientSettings().WithGlobalTimeout(0) + if settings.HTTPProxy == nil { + settings.HTTPProxy = http.ProxyFromEnvironment } - if proxy, err := wa.getProxy(ctx, reason); err != nil { - return fmt.Errorf("failed to get proxy address: %w", err) - } else if proxy == "" { - return nil - } else if err = client.SetProxyAddress(proxy, whatsmeow.SetProxyOptions{ - OnlyLogin: wa.Config.ProxyOnlyLogin, - NoMedia: wa.Config.ProxyOnlyLogin, - }); err != nil { - return fmt.Errorf("failed to set proxy address: %w", err) + proxySettings := settings + if proxy != "" { + var err error + proxySettings, err = settings.WithProxy(proxy) + if err != nil { + return fmt.Errorf("failed to set proxy address: %w", err) + } + } + client.SetPreLoginHTTPClient(proxySettings.Compile()) + if wa.Config.ProxyOnlyLogin { + proxySettings = settings + } + client.SetWebsocketHTTPClient(proxySettings.Compile()) + client.SetMediaHTTPClient(proxySettings.WithResponseHeaderTimeout(0).Compile()) + if proxy != "" { + zerolog.Ctx(ctx).Debug().Msg("Enabled proxy") } - zerolog.Ctx(ctx).Debug().Msg("Enabled proxy") return nil } From 96ce0799be8c962d64cde63ab383a925d7a8fd84 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 2 Nov 2026 12:57:03 +0000 Subject: [PATCH 42/56] handlewhatsapp: don't retry full sync after a recovery response (#1018) --- pkg/connector/handlewhatsapp.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index a7d4c918..4391233b 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -970,7 +970,9 @@ func (wa *WhatsAppClient) handleWAAppStateSyncComplete(ctx context.Context, evt defer wa.appStateRecoveryLock.Unlock() meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata) if ts, exists := meta.AppStateRecoveryAttempted[evt.Name]; exists { - delete(wa.appStateFullSyncAttempted, evt.Name) + if !evt.Recovery { + delete(wa.appStateFullSyncAttempted, evt.Name) + } delete(meta.AppStateRecoveryAttempted, evt.Name) err := wa.UserLogin.Save(ctx) if err != nil { @@ -1008,7 +1010,7 @@ func (wa *WhatsAppClient) handleWAAppStateSyncError(ctx context.Context, evt *ev return } if !evt.FullSync { - if !lastFullSync.IsZero() { + if !lastFullSync.IsZero() && time.Since(lastFullSync) < 48*time.Hour { log.Debug(). Err(evt.Error). Time("last_full_sync_attempt", lastFullSync). From 4be45ddbea7d2aec2fe977cf3ea2d62c485a3a47 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:28:04 -0500 Subject: [PATCH 43/56] handlewhatsapp: send media retry edits as the original message sender (#1019) Co-authored-by: Indent --- pkg/connector/events.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/connector/events.go b/pkg/connector/events.go index 481d0d73..7f906e89 100644 --- a/pkg/connector/events.go +++ b/pkg/connector/events.go @@ -474,7 +474,9 @@ func (evt *WAMediaRetry) getRealSender() types.JID { func (evt *WAMediaRetry) GetSender() bridgev2.EventSender { realSender := pickLID(evt.getRealSender(), evt.senderLID) - return evt.wa.makeEventSender(evt.wa.Main.Bridge.BackgroundCtx, realSender) + sender := evt.wa.makeEventSender(evt.wa.Main.Bridge.BackgroundCtx, realSender) + sender.ForceEditOrigSender = true + return sender } func (evt *WAMediaRetry) GetTargetMessage() networkid.MessageID { From e097ba38de1f5d450013eb52b3136f28c7131dea Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 2 Nov 2026 15:14:20 +0000 Subject: [PATCH 44/56] userinfo: don't copy unauthorized avatar to alt ghost (#1020) --- pkg/connector/userinfo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index b109e82d..3d5499c0 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -480,7 +480,7 @@ func makeInfoFromGhost(ghost *bridgev2.Ghost) *bridgev2.UserInfo { IsBot: &ghost.IsBot, ExtraProfile: ghost.ExtraProfile, } - if ghost.AvatarID != "" { + if ghost.AvatarID != "" && ghost.AvatarID != "unauthorized" { info.Avatar = &bridgev2.Avatar{ ID: ghost.AvatarID, Remove: ghost.AvatarMXC == "", From 78c5ee3b207d982cab19bec7424279e3fc365071 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:41:06 -0500 Subject: [PATCH 45/56] backfill: expire unanswered on-demand history sync requests after an hour (#1021) Co-authored-by: Indent --- pkg/connector/backfill.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index fe251e1d..343b2405 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -799,10 +799,12 @@ type onDemandHistorySyncRequest struct { SentAt time.Time } +const onDemandHistorySyncRequestTimeout = 1 * time.Hour + func (wa *WhatsAppClient) startOnDemandHistorySyncRequest(ctx context.Context, portalJID types.JID, msgID types.MessageID) bool { wa.onDemandHistorySyncLock.Lock() defer wa.onDemandHistorySyncLock.Unlock() - if req, inFlight := wa.onDemandHistorySyncRequests[portalJID]; inFlight { + if req, inFlight := wa.onDemandHistorySyncRequests[portalJID]; inFlight && time.Since(req.SentAt) < onDemandHistorySyncRequestTimeout { zerolog.Ctx(ctx).Debug(). Str("in_flight_request_msg_id", req.ID). Stringer("portal_jid", portalJID). From 429320b373d2154cfdcca3e6981e2af9f7f00ee1 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Tue, 3 Nov 2026 21:35:54 +0000 Subject: [PATCH 46/56] pre-commit: add bridgelint hook (#1022) --- .pre-commit-config.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1bfb5360..d6c78bb4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,9 +21,11 @@ repos: - id: go-mod-tidy - repo: https://github.com/beeper/pre-commit-go - rev: v0.4.2 + rev: v0.5.0 hooks: - id: zerolog-ban-msgf - id: zerolog-use-stringer - id: prevent-literal-http-methods - id: zerolog-ban-global-log + - id: bridgelint + verbose: true From 5ba69ab4ee9c916c488ada28c6086d7ec382225c Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Wed, 4 Nov 2026 16:24:07 +0000 Subject: [PATCH 47/56] backfill: drop history sync notifications whose blob is gone (#1023) --- pkg/connector/backfill.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 343b2405..cbc7eec8 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -142,8 +142,16 @@ func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context, Str("peer_data_request_session_id", evt.GetPeerDataRequestSessionID()). Msg("Downloading history sync") blob, err := wa.Client.DownloadHistorySync(log.WithContext(ctx), evt, true) - if err != nil { + if errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith403) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsmeow.ErrMediaDownloadFailedWith410) { + log.Err(err).Msg("History sync blob is no longer available, dropping notification") + if err = wa.Main.DB.HSNotif.Delete(ctx, rowid); err != nil { + log.Err(err).Msg("Failed to delete queued history sync notification") + errored = true + } + return + } else if err != nil { log.Err(err).Msg("Failed to download history sync") + errored = true return } if blob.GetSyncType() == waHistorySync.HistorySync_ON_DEMAND { @@ -171,6 +179,7 @@ func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context, }) if err != nil { log.Err(err).Msg("Failed to store history sync notification data") + errored = true } else { resetTimer = blob.GetSyncType() == waHistorySync.HistorySync_INITIAL_BOOTSTRAP || blob.GetSyncType() == waHistorySync.HistorySync_RECENT || From 1d024590d2df575f1f2e184229ace51b0300ff70 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:16:41 -0500 Subject: [PATCH 48/56] userinfo: don't queue @bot ghosts for background resync (#1024) Co-authored-by: Indent --- pkg/connector/userinfo.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 3d5499c0..b6594534 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -29,12 +29,15 @@ var ResyncLoopInterval = 4 * time.Hour var ResyncJitterSeconds = 3600 func (wa *WhatsAppClient) EnqueueGhostResync(ghost *bridgev2.Ghost) { + jid := waid.ParseUserID(ghost.ID) + if jid.Server == types.BotServer { + return + } lastSync := ghost.Metadata.(*waid.GhostMetadata).LastSync.Time if ghost.AvatarID != "" && lastSync.Add(ResyncMinInterval).After(time.Now()) { return } wa.resyncQueueLock.Lock() - jid := waid.ParseUserID(ghost.ID) if _, exists := wa.resyncQueue[jid]; !exists { wa.resyncQueue[jid] = resyncQueueItem{ghost: ghost} nextResyncIn := time.Until(wa.nextResync).String() From 03859bb3db0b7f597bf93f2f35a7306879b85dcc Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Thu, 5 Nov 2026 13:09:38 +0000 Subject: [PATCH 49/56] pre-commit: install bridgelint with the CI Go toolchain (#1030) --- .github/workflows/go.yml | 1 + .pre-commit-config.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index c9fef736..146b93a0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -30,6 +30,7 @@ jobs: run: | go install golang.org/x/tools/cmd/goimports@latest go install honnef.co/go/tools/cmd/staticcheck@latest + go install github.com/beeper/pre-commit-go/bridgelint@latest export PATH="$HOME/go/bin:$PATH" - name: Run pre-commit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d6c78bb4..1c96b066 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,4 +28,5 @@ repos: - id: prevent-literal-http-methods - id: zerolog-ban-global-log - id: bridgelint + language: system verbose: true From 81f64557ea4f8535a4323135f42569b8e26646a5 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 5 Nov 2026 09:16:26 -0500 Subject: [PATCH 50/56] handlematrix: ignore thread read receipts Closes #1028 --- go.mod | 2 +- go.sum | 4 ++-- pkg/connector/handlematrix.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 6e9b5033..f940c1de 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( golang.org/x/sync v0.24.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.32.1-0.20261031224934-b69deb427bd7 + maunium.net/go/mautrix v0.32.1-0.20261105141340-842796acf445 ) require ( diff --git a/go.sum b/go.sum index 12cf8f53..fb4e878f 100644 --- a/go.sum +++ b/go.sum @@ -103,5 +103,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/mautrix v0.32.1-0.20261031224934-b69deb427bd7 h1:GpLIqeJTpG0/ti7XA370HgjnSWd/pQxXVGb/FRkayOA= -maunium.net/go/mautrix v0.32.1-0.20261031224934-b69deb427bd7/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= +maunium.net/go/mautrix v0.32.1-0.20261105141340-842796acf445 h1:ZI9Y2YEmjqQDloOCiciHeynTRYjUwK0ZZbNcRH/5NOc= +maunium.net/go/mautrix v0.32.1-0.20261105141340-842796acf445/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index d51efcd2..84198506 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -319,7 +319,7 @@ func (wa *WhatsAppClient) HandleMatrixMessageRemove(ctx context.Context, msg *br } func (wa *WhatsAppClient) HandleMatrixReadReceipt(ctx context.Context, receipt *bridgev2.MatrixReadReceipt) (retErr error) { - if !receipt.ReadUpTo.After(receipt.LastRead) { + if receipt.Receipt.IsThread() || !receipt.ReadUpTo.After(receipt.LastRead) { return nil } if receipt.LastRead.IsZero() { From 5640ef311b5aef90ee0d35766ec9c9bf167d25af Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Thu, 5 Nov 2026 15:00:20 +0000 Subject: [PATCH 51/56] muse: mark WASA resync as done on any error (#1026) --- pkg/connector/muse.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/connector/muse.go b/pkg/connector/muse.go index ee6b04d9..4a589ce7 100644 --- a/pkg/connector/muse.go +++ b/pkg/connector/muse.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "runtime/debug" "time" @@ -57,9 +56,9 @@ func (wa *WhatsAppClient) resyncWASARootSecrets(ctx context.Context) { success := true if err := wa.Client.FetchAppState(ctx, appstate.WAPatchRegularHigh, true, false); err != nil { log.Err(err).Msg("Failed to resync WASA root secrets") - // Mark the sync as completed on mismatching LTHash errors, because those can get permanently stuck - // (retrying won't help). In case it's not stuck, a single call should be enough to request a resync. - if !errors.Is(err, appstate.ErrMismatchingLTHash) || ctx.Err() != nil { + // Mark the sync as completed on errors too, because some get permanently stuck (mismatching LTHash, + // expired patch blobs), and a failed full sync already requests app state recovery from the phone. + if ctx.Err() != nil { return } success = false From d574a67a2d22a15af72d953ff5c2d44adfe8562c Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:33:02 +0000 Subject: [PATCH 52/56] userinfo: query only the LID when both JIDs of a contact are queued for background resync (#1029) Co-authored-by: Indent --- pkg/connector/userinfo.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index b6594534..387971e9 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -119,6 +119,7 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID var ghostJIDs []types.JID var ghosts []*bridgev2.Ghost var portals []*bridgev2.Portal + selectedGhosts := make(map[types.JID]*bridgev2.Ghost) for jid, item := range queue { var lastSync time.Time unknownAvatar := false @@ -136,12 +137,23 @@ func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID continue } if item.ghost != nil { - ghosts = append(ghosts, item.ghost) - ghostJIDs = append(ghostJIDs, jid) + selectedGhosts[jid] = item.ghost } else if item.portal != nil { portals = append(portals, item.portal) } } + for jid, ghost := range selectedGhosts { + if jid.Server == types.DefaultUserServer { + lid, err := wa.GetStore().LIDs.GetLIDForPN(ctx, jid) + if err != nil { + log.Warn().Err(err).Stringer("jid", jid).Msg("Failed to get LID for background sync") + } else if _, lidSelected := selectedGhosts[lid]; lidSelected { + continue + } + } + ghosts = append(ghosts, ghost) + ghostJIDs = append(ghostJIDs, jid) + } for _, portal := range portals { wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ From ac29fa5272ed896586320f92dfa63d305883f895 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Fri, 6 Nov 2026 20:37:55 +0000 Subject: [PATCH 53/56] lidmigrate: check homeserver membership when removing rejoined PN ghosts (#1025) --- pkg/connector/lidmigrate.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/pkg/connector/lidmigrate.go b/pkg/connector/lidmigrate.go index 17b24287..4295be63 100644 --- a/pkg/connector/lidmigrate.go +++ b/pkg/connector/lidmigrate.go @@ -300,7 +300,11 @@ func (wa *WhatsAppConnector) syncMismatchingGhosts(ctx context.Context) error { } func (wa *WhatsAppConnector) removeRejoinedPNGhosts(ctx context.Context) error { - if wa.Bridge.Background || wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_dm_pn_ghosts_removed") == "true" { + if wa.Bridge.Background || wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_dm_pn_ghosts_removed_v2") == "true" { + return nil + } + stateGetter, ok := wa.Bridge.Matrix.(bridgev2.MatrixConnectorWithArbitraryRoomState) + if !ok { return nil } log := zerolog.Ctx(ctx).With().Str("action", "remove rejoined pn ghosts").Logger() @@ -322,12 +326,14 @@ func (wa *WhatsAppConnector) removeRejoinedPNGhosts(ctx context.Context) error { continue } pnUserID := waid.MakeUserID(pn) - member, err := wa.Bridge.Matrix.GetMemberInfo(ctx, portal.MXID, wa.Bridge.Matrix.FormatGhostMXID(pnUserID)) - if err != nil { + memberEvt, err := stateGetter.GetStateEvent(ctx, portal.MXID, event.StateMember, wa.Bridge.Matrix.FormatGhostMXID(pnUserID).String()) + if errors.Is(err, mautrix.MNotFound) || errors.Is(err, mautrix.MForbidden) { + continue + } else if err != nil { log.Warn().Err(err).Stringer("room_id", portal.MXID).Msg("Failed to get PN ghost membership") failed++ continue - } else if member == nil || member.Membership != event.MembershipJoin { + } else if memberEvt == nil || memberEvt.Content.AsMember().Membership != event.MembershipJoin { continue } pnGhost, err := wa.Bridge.GetGhostByID(ctx, pnUserID) @@ -345,6 +351,12 @@ func (wa *WhatsAppConnector) removeRejoinedPNGhosts(ctx context.Context) error { } continue } + log.Debug(). + Stringer("room_id", portal.MXID). + Stringer("pn", pn). + Stringer("join_sender", memberEvt.Sender). + Time("join_ts", time.UnixMilli(memberEvt.Timestamp)). + Msg("Removed PN ghost from LID DM") removed++ } log.Info(). @@ -353,7 +365,7 @@ func (wa *WhatsAppConnector) removeRejoinedPNGhosts(ctx context.Context) error { Int("total_lid_portals", len(lidPortals)). Msg("Finished removing rejoined PN ghosts from LID DMs") if failed == 0 && ctx.Err() == nil { - wa.Bridge.DB.KV.Set(ctx, "whatsapp_lid_dm_pn_ghosts_removed", "true") + wa.Bridge.DB.KV.Set(ctx, "whatsapp_lid_dm_pn_ghosts_removed_v2", "true") } return nil } From d9b0daca891ac69226931eba5594e0f490aaee51 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:46:19 -0500 Subject: [PATCH 54/56] msgconv/rich-response: only embed raw rich message JSON if it's canonical (#1033) Co-authored-by: Indent --- pkg/msgconv/wa-rich-response.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/msgconv/wa-rich-response.go b/pkg/msgconv/wa-rich-response.go index 28a648c3..e506a64e 100644 --- a/pkg/msgconv/wa-rich-response.go +++ b/pkg/msgconv/wa-rich-response.go @@ -29,6 +29,7 @@ import ( "github.com/yuin/goldmark/extension" "go.mau.fi/whatsmeow/types/richresponse" "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/crypto/canonicaljson" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format/mdext" @@ -36,7 +37,8 @@ import ( func (mc *MessageConverter) convertUnifiedRichResponseMessage(ctx context.Context, data []byte) *bridgev2.ConvertedMessagePart { extra := map[string]any{} - if json.Valid(data) && len(data) < 24*1024 { + _, canonicalErr := canonicaljson.Marshal(json.RawMessage(data)) + if canonicalErr == nil && len(data) < 24*1024 { extra["fi.mau.whatsapp.rich_message"] = json.RawMessage(data) } else if len(data) < 16*1024 { extra["fi.mau.whatsapp.non_json_rich_message"] = base64.StdEncoding.EncodeToString(data) From 5c8c1fc8358c011891fbf6066f289b9ad5fee223 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:09:28 +0000 Subject: [PATCH 55/56] handlewhatsapp: ignore HD replacement messages without replacement content (#1037) Co-authored-by: Indent --- pkg/connector/handlewhatsapp.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index 4391233b..e6174b92 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -374,6 +374,14 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa if assocType := messageAssoc.GetAssociationType(); assocType == waE2E.MessageAssociation_HD_IMAGE_DUAL_UPLOAD || assocType == waE2E.MessageAssociation_HD_VIDEO_DUAL_UPLOAD { parentKey := messageAssoc.GetParentMessageKey() protocolMsg, shouldHideEdit := makeHDMediaReplacementEdit(evt.Message, parentKey) + if protocolMsg.GetEditedMessage() == nil { + wa.UserLogin.Log.Debug(). + Str("message_id", evt.Info.ID). + Str("parent_id", parentKey.GetID()). + Stringer("assoc_type", assocType). + Msg("Ignoring HD replacement message without replacement content") + return + } dontRenderEdited = shouldHideEdit wa.UserLogin.Log.Debug(). Str("message_id", evt.Info.ID). From 945474343fcd7579c3d273acb78f6c60c957e2e0 Mon Sep 17 00:00:00 2001 From: "indent[bot]" <297468268+indent[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:09:56 +0000 Subject: [PATCH 56/56] backfill: log stub and message type of unparseable history messages (#1034) Co-authored-by: Indent --- pkg/connector/backfill.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index cbc7eec8..8f006e0b 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -298,6 +298,9 @@ func (wa *WhatsAppClient) handleWAHistorySync( Int("msg_index", i). Str("msg_id", rawMsg.GetMessage().GetKey().GetID()). Uint64("msg_time_seconds", rawMsg.GetMessage().GetMessageTimestamp()). + Stringer("stub_type", rawMsg.GetMessage().GetMessageStubType()). + Str("message_type", getMessageType(rawMsg.GetMessage().GetMessage())). + Bool("from_me", rawMsg.GetMessage().GetKey().GetFromMe()). Msg("Dropping historical message due to parse error") log.Trace(). Any("web_message_info", rawMsg.GetMessage()).