From 7ee5aa647cd8a35881b4ea2d4e6c0f9067e48097 Mon Sep 17 00:00:00 2001 From: Kishan Bagaria <1093313+KishanBagaria@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:02:48 -0700 Subject: [PATCH 001/203] - --- pkg/connector/wamsgtype.go | 2 + pkg/msgconv/from-whatsapp.go | 4 ++ pkg/msgconv/wa-misc.go | 81 ++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/pkg/connector/wamsgtype.go b/pkg/connector/wamsgtype.go index 92e7f869..e716f5d2 100644 --- a/pkg/connector/wamsgtype.go +++ b/pkg/connector/wamsgtype.go @@ -132,6 +132,8 @@ func getMessageType(waMsg *waE2E.Message) string { return "poll result snapshot" case waMsg.MessageHistoryBundle != nil: return "message history bundle" + case waMsg.MessageHistoryNotice != nil: + return "message history notice" case waMsg.RequestPhoneNumberMessage != nil: return "request phone number" case waMsg.KeepInChatMessage != nil: diff --git a/pkg/msgconv/from-whatsapp.go b/pkg/msgconv/from-whatsapp.go index 11569e29..2117535b 100644 --- a/pkg/msgconv/from-whatsapp.go +++ b/pkg/msgconv/from-whatsapp.go @@ -217,6 +217,10 @@ func (mc *MessageConverter) ToMatrix( part, contextInfo = mc.convertGroupInviteMessage(ctx, info, waMsg.GroupInviteMessage) case waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.GetType() == waE2E.ProtocolMessage_EPHEMERAL_SETTING: part, contextInfo = mc.convertEphemeralSettingMessage(ctx, waMsg.ProtocolMessage, info.Timestamp, isBackfill) + case waMsg.MessageHistoryBundle != nil: + part, contextInfo = mc.convertMessageHistoryShare(ctx, info, waMsg.MessageHistoryBundle.GetMessageHistoryMetadata(), waMsg.MessageHistoryBundle.GetContextInfo()) + case waMsg.MessageHistoryNotice != nil: + part, contextInfo = mc.convertMessageHistoryShare(ctx, info, waMsg.MessageHistoryNotice.GetMessageHistoryMetadata(), waMsg.MessageHistoryNotice.GetContextInfo()) case waMsg.EncCommentMessage != nil: part = failedCommentPart default: diff --git a/pkg/msgconv/wa-misc.go b/pkg/msgconv/wa-misc.go index 7ca44274..9222f78e 100644 --- a/pkg/msgconv/wa-misc.go +++ b/pkg/msgconv/wa-misc.go @@ -21,6 +21,7 @@ import ( "encoding/base64" "fmt" "html/template" + "slices" "strings" "time" @@ -73,6 +74,86 @@ func (mc *MessageConverter) convertPlaceholderMessage(ctx context.Context, rawMs } } +func joinNaturalNames(names []string) string { + names = slices.DeleteFunc(names, func(name string) bool { + return name == "" + }) + switch len(names) { + case 0: + return "" + case 1: + return names[0] + case 2: + return names[0] + " and " + names[1] + default: + return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1] + } +} + +func (mc *MessageConverter) getHistoryReceiverName(ctx context.Context, receiver string) string { + jid, err := types.ParseJID(receiver) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("receiver_jid", receiver).Msg("Failed to parse message history receiver JID") + return receiver + } + _, displayname, err := mc.getBasicUserInfo(ctx, jid) + if err != nil { + zerolog.Ctx(ctx).Err(err).Stringer("receiver_jid", jid).Msg("Failed to resolve message history receiver") + if jid.User != "" { + return jid.User + } + return receiver + } + return displayname +} + +func messageHistoryStartTime(metadata *waE2E.MessageHistoryMetadata, fallback time.Time) time.Time { + if metadata == nil { + return fallback + } + if ts := metadata.GetOldestMessageTimestamp(); ts > 0 { + return time.Unix(ts, 0).Local() + } + return fallback +} + +func (mc *MessageConverter) convertMessageHistoryShare(ctx context.Context, info *types.MessageInfo, metadata *waE2E.MessageHistoryMetadata, contextInfo *waE2E.ContextInfo) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { + names := make([]string, 0, len(metadata.GetHistoryReceivers())) + for _, receiver := range metadata.GetHistoryReceivers() { + names = append(names, mc.getHistoryReceiverName(ctx, receiver)) + } + + receivers := joinNaturalNames(names) + var fallback time.Time + if info != nil { + fallback = info.Timestamp + } + startAt := messageHistoryStartTime(metadata, fallback) + body := "Message history shared." + if !startAt.IsZero() { + startTime := startAt.Format("Jan 2, 2006 at 3:04 PM") + body = fmt.Sprintf("Message history shared starting on %s.", startTime) + switch { + case info != nil && info.IsFromMe && receivers != "": + body = fmt.Sprintf("You sent %s message history that starts on %s.", receivers, startTime) + case receivers != "": + body = fmt.Sprintf("Sent %s message history that starts on %s.", receivers, startTime) + } + } else if info != nil && info.IsFromMe && receivers != "" { + body = fmt.Sprintf("You sent %s message history.", receivers) + } else if receivers != "" { + body = fmt.Sprintf("Sent %s message history.", receivers) + } + + return &bridgev2.ConvertedMessagePart{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: body, + }, + }, contextInfo +} + const inviteMsg = `%s
%s accept to accept the invite.`
const inviteMsgBroken = `%s%[1]s`, html.EscapeString(meta.LatexExpression)),
+ )
+ default:
+ text.Text = strings.ReplaceAll(
+ text.Text,
+ fmt.Sprintf("{{%s}}", ent.Key),
+ ent.Key,
+ )
+ }
+ }
+ return mdRender.Convert([]byte(text.Text), buf)
+}
+
+func (mc *MessageConverter) convertRichResponsePrimitive(ctx context.Context, primitive richresponse.Primitive, buf *strings.Builder) error {
+ switch p := primitive.(type) {
+ case *richresponse.GenAICodeUXPrimitive:
+ if p.Language != "" {
+ _, _ = fmt.Fprintf(buf, ``, html.EscapeString(p.Language))
+ } else {
+ buf.WriteString("")
+ }
+ for _, part := range p.CodeBlocks {
+ buf.WriteString(part.Content)
+ }
+ buf.WriteString("
")
+ case *richresponse.GenAIMarkdownTextUXPrimitive:
+ return mc.convertRichMarkdownText(ctx, p, buf)
+ case *richresponse.GenATableUXPrimitive:
+ buf.WriteString("")
+ for _, row := range p.Rows {
+ buf.WriteString("")
+ cellType := "tr"
+ if row.IsHeader {
+ cellType = "th"
+ }
+ if len(row.MarkdownCells) > 0 {
+ for _, cell := range row.MarkdownCells {
+ _, _ = fmt.Fprintf(buf, "<%s>", cellType)
+ err := mc.convertRichMarkdownText(ctx, &cell, buf)
+ if err != nil {
+ return err
+ }
+ _, _ = fmt.Fprintf(buf, "%s>", cellType)
+ }
+ } else {
+ for _, cell := range row.Cells {
+ _, _ = fmt.Fprintf(buf, "<%s>%s%s>", cellType, html.EscapeString(cell), cellType)
+ }
+ }
+ buf.WriteString(" ")
+ }
+ buf.WriteString("
")
+ case *richresponse.GenAILatexUXPrimitive:
+ _, _ = fmt.Fprintf(buf, `%[1]s`, html.EscapeString(p.GetLatexExpression()))
+ case *richresponse.FOATextPrimitive:
+ // TODO remove placeholders?
+ return mdRender.Convert([]byte(p.Text), buf)
+ case *richresponse.GenAIMetadataTextPrimitive:
+ return mdRender.Convert([]byte(p.Text), buf)
+ //case *richresponse.GenAIBotThinkingStatusPrimitive:
+ //case *richresponse.GenAIProductItemCardPrimitive:
+ //case *richresponse.GenAIImagePrimitive:
+ //case *richresponse.GenAITaskPrimitive:
+ //case *richresponse.GenAIReelPrimitive:
+ //case *richresponse.GenAIPostPrimitive:
+ //case *richresponse.GenAIImaginePrimitive:
+ //case *richresponse.GenAISearchResultPrimitive:
+ //case *richresponse.FOABloksPrimitive:
+ case *richresponse.GenAIDividerPrimitive:
+ // TODO dots?
+ buf.WriteString("
")
+ case *richresponse.GenAISpacerPrimitive:
+ if p.Spacing <= 1 {
+ buf.WriteString("
")
+ } else {
+ for range p.Spacing {
+ buf.WriteString("
")
+ }
+ }
+ default:
+ buf.WriteString("Unknown primitive type
")
+ }
+ return nil
+}
From 85aa4a6d0d2a2a34954231f30f9c9939d6b0d47e Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Mon, 21 Sep 2026 15:56:51 +0300
Subject: [PATCH 126/203] msgconv/from-whatsapp: cut off too long messages
---
go.mod | 2 +-
go.sum | 4 ++--
pkg/msgconv/wa-text.go | 6 ++++++
3 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index fc817495..97c0a0a0 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,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.0
+ maunium.net/go/mautrix v0.31.1-0.20260921130557-78e83388b27e
)
require (
diff --git a/go.sum b/go.sum
index 4a39eb48..d7de15c9 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.0 h1:x6XNBSa0kOaBgcleI+ZBLkGvBdHIE0qdeY6TZ4wWUeI=
-maunium.net/go/mautrix v0.31.0/go.mod h1:vfPYtoGAjlTnIF+W8tl+rjX3yB4PEfqYR2sDXvQo4Co=
+maunium.net/go/mautrix v0.31.1-0.20260921130557-78e83388b27e h1:ndauW8laSq7G105w9hPkTjwaOw5/f2z2SgMr9Yq6MKw=
+maunium.net/go/mautrix v0.31.1-0.20260921130557-78e83388b27e/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk=
diff --git a/pkg/msgconv/wa-text.go b/pkg/msgconv/wa-text.go
index 032e3fd8..7859f37f 100644
--- a/pkg/msgconv/wa-text.go
+++ b/pkg/msgconv/wa-text.go
@@ -42,6 +42,12 @@ 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
}
From 52f0c60aa60efd39fce13fa65113f026f50db43a Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Thu, 24 Sep 2026 15:04:51 +0300
Subject: [PATCH 127/203] handlewhatsapp: dispatch unavailable resend responses
directly as edits
---
pkg/connector/events.go | 5 ++++-
pkg/connector/handlewhatsapp.go | 18 +++++++++++++++---
2 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/pkg/connector/events.go b/pkg/connector/events.go
index 1f4e118b..c23189d6 100644
--- a/pkg/connector/events.go
+++ b/pkg/connector/events.go
@@ -315,7 +315,10 @@ func (evt *WANowDecryptableMessage) GetTargetMessage() networkid.MessageID {
}
func (evt *WANowDecryptableMessage) AddLogContext(c zerolog.Context) zerolog.Context {
- return c
+ if evt.editParts != nil {
+ return c.Bool("from_upsert", true)
+ }
+ return evt.WAMessageEvent.AddLogContext(c.Bool("from_upsert", false))
}
func (evt *WANowDecryptableMessage) GetType() bridgev2.RemoteEventType {
diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go
index 53813117..dba4f1ee 100644
--- a/pkg/connector/handlewhatsapp.go
+++ b/pkg/connector/handlewhatsapp.go
@@ -371,7 +371,7 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
return
}
- res := wa.UserLogin.QueueRemoteEvent(&WAMessageEvent{
+ wrappedEvt := &WAMessageEvent{
MessageInfoWrapper: &MessageInfoWrapper{
Info: evt.Info,
wa: wa,
@@ -381,8 +381,20 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
parsedMessageType: parsedMessageType,
dontRenderEdited: dontRenderEdited,
- })
- return res.Success
+ }
+ if evt.UnavailableRequestID != "" {
+ wa.UserLogin.Log.Debug().
+ Str("message_id", evt.Info.ID).
+ Str("unavailable_request_id", evt.UnavailableRequestID).
+ Msg("Received placeholder resend response")
+ wa.trackUndecryptableResolved(evt)
+ wrappedEvt.isUndecryptableUpsertSubEvent = true
+ // Dispatch directly as edit instead of the usual upsert, so that the message doesn't
+ // get re-bridged if it was deleted before the resend response was received.
+ return wa.UserLogin.QueueRemoteEvent(&WANowDecryptableMessage{WAMessageEvent: wrappedEvt}).Success
+ }
+
+ return wa.UserLogin.QueueRemoteEvent(wrappedEvt).Success
}
func makeHDMediaReplacementEdit(message *waE2E.Message, parentKey *waCommon.MessageKey) (*waE2E.ProtocolMessage, bool) {
From 173f18405c619fa69708c54ec72bb5c64bd031f8 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Fri, 25 Sep 2026 19:27:23 +0300
Subject: [PATCH 128/203] login: wait for otk upload before confirming login
success
---
go.mod | 2 +-
go.sum | 4 ++--
pkg/connector/client.go | 10 ++++++++--
pkg/connector/login.go | 10 ++++++++--
4 files changed, 19 insertions(+), 7 deletions(-)
diff --git a/go.mod b/go.mod
index 97c0a0a0..6f12b2a5 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,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-20260921121126-35ae40906e74
+ go.mau.fi/whatsmeow v0.0.0-20260925162019-b3832c2bd1d1
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 d7de15c9..7e657875 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-20260921121126-35ae40906e74 h1:z3cItqSX0u+rsM6/81MB1I4bBa92jPAf6QZAvmGc0L0=
-go.mau.fi/whatsmeow v0.0.0-20260921121126-35ae40906e74/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4=
+go.mau.fi/whatsmeow v0.0.0-20260925162019-b3832c2bd1d1 h1:iGMEoW98F/m73hSf4+EwV8l5yVfsZGFqvYRM7dAMWs4=
+go.mau.fi/whatsmeow v0.0.0-20260925162019-b3832c2bd1d1/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/client.go b/pkg/connector/client.go
index a9099467..2287fead 100644
--- a/pkg/connector/client.go
+++ b/pkg/connector/client.go
@@ -206,13 +206,17 @@ func (wa *WhatsAppClient) GetLID() types.JID {
}
func (wa *WhatsAppClient) Connect(ctx context.Context) {
+ wa.connect(ctx)
+}
+
+func (wa *WhatsAppClient) connect(ctx context.Context) bool {
if wa.Client == nil {
state := status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: WANotLoggedIn,
}
wa.UserLogin.BridgeState.Send(state)
- return
+ return false
}
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting})
wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect)
@@ -220,7 +224,7 @@ func (wa *WhatsAppClient) Connect(ctx context.Context) {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy")
}
if ctx.Err() != nil {
- return
+ return false
}
wa.initMC()
wa.startLoops()
@@ -237,7 +241,9 @@ func (wa *WhatsAppClient) Connect(ctx context.Context) {
},
}
wa.UserLogin.BridgeState.Send(state)
+ return false
}
+ return true
}
func (wa *WhatsAppClient) notifyOfflineSyncWaiter(err error) {
diff --git a/pkg/connector/login.go b/pkg/connector/login.go
index 80a6abbb..fa2c4792 100644
--- a/pkg/connector/login.go
+++ b/pkg/connector/login.go
@@ -410,8 +410,14 @@ func (wl *WALogin) onLoginComplete(ctx context.Context) (*bridgev2.LoginStep, er
return nil, fmt.Errorf("failed to create user login: %w", err)
}
- ul.Client.(*WhatsAppClient).isNewLogin = true
- ul.Client.Connect(ul.Log.WithContext(wl.Main.Bridge.BackgroundCtx))
+ c := ul.Client.(*WhatsAppClient)
+ c.isNewLogin = true
+ if c.connect(ul.Log.WithContext(wl.Main.Bridge.BackgroundCtx)) {
+ err = c.Client.PreKeysUploaded.WaitTimeoutCtx(ctx, 5*time.Second)
+ if err != nil {
+ wl.Log.Warn().Err(err).Msg("Prekey upload wait failed")
+ }
+ }
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,
From 054064eddb27405fe353f0a6dfc75a00e4f93982 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Sun, 27 Sep 2026 18:04:19 +0300
Subject: [PATCH 129/203] backfill,msgconv: handle panics in on-demand history
syncs
---
pkg/connector/backfill.go | 32 +++++++++++++++++++++++++++++---
pkg/msgconv/from-matrix.go | 2 +-
pkg/msgconv/from-whatsapp.go | 14 ++++++++++++--
pkg/msgconv/matrixpoll.go | 6 +++---
pkg/msgconv/wa-poll.go | 6 +++---
5 files changed, 48 insertions(+), 12 deletions(-)
diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go
index e0f5ffc6..50a0d4c2 100644
--- a/pkg/connector/backfill.go
+++ b/pkg/connector/backfill.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "runtime/debug"
"slices"
"strconv"
"strings"
@@ -12,6 +13,7 @@ import (
"time"
"github.com/rs/zerolog"
+ "go.mau.fi/util/exerrors"
"go.mau.fi/util/exmaps"
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
@@ -62,7 +64,7 @@ func (wa *WhatsAppClient) historySyncLoop(ctx context.Context) {
}
}()
for {
- var resetTimer bool
+ var resetTimer, errored bool
select {
case <-wa.historySyncWakeup:
dispatchTimer.Stop()
@@ -72,12 +74,21 @@ func (wa *WhatsAppClient) historySyncLoop(ctx context.Context) {
} else if notif == nil {
wa.UserLogin.Log.Debug().Msg("No more queued history sync notifications")
} else {
- resetTimer = wa.downloadAndSaveWAHistorySyncData(ctx, notif, rowid)
+ resetTimer, errored = wa.downloadAndSaveWAHistorySyncData(ctx, notif, rowid)
// Continue waking up the loop until all queued notifications are processed
select {
case wa.historySyncWakeup <- struct{}{}:
default:
}
+ if errored {
+ wa.UserLogin.Log.Debug().Msg("Temporarily stopping history sync loop after error")
+ select {
+ case <-time.After(1 * time.Minute):
+ case <-ctx.Done():
+ wa.UserLogin.Log.Debug().Msg("Stopping main history sync loop")
+ return
+ }
+ }
}
case <-ctx.Done():
wa.UserLogin.Log.Debug().Msg("Stopping main history sync loop")
@@ -109,13 +120,21 @@ func (wa *WhatsAppClient) saveWAHistorySyncNotification(ctx context.Context, evt
}
}
-func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context, evt *waE2E.HistorySyncNotification, rowid int) (resetTimer bool) {
+func (wa *WhatsAppClient) downloadAndSaveWAHistorySyncData(ctx context.Context, evt *waE2E.HistorySyncNotification, rowid int) (resetTimer, errored bool) {
log := wa.UserLogin.Log.With().
Str("action", "download history sync").
Stringer("sync_type", evt.GetSyncType()).
Uint32("chunk_order", evt.GetChunkOrder()).
Uint32("progress", evt.GetProgress()).
Logger()
+ defer func() {
+ if v := recover(); v != nil {
+ log.Err(exerrors.RecoverToError(v)).
+ Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
+ Msg("History sync saving panicked")
+ errored = true
+ }
+ }()
log.Debug().
Int64("oldest_msg_in_chunk_ts", evt.GetOldestMsgInChunkTimestampSec()).
Any("full_request_meta", evt.GetFullHistorySyncOnDemandRequestMetadata()).
@@ -367,6 +386,13 @@ func (wa *WhatsAppClient) createPortalsFromHistorySync(ctx context.Context) {
log := wa.UserLogin.Log.With().
Str("action", "create portals from history sync").
Logger()
+ defer func() {
+ if v := recover(); v != nil {
+ log.Err(exerrors.RecoverToError(v)).
+ Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
+ Msg("History sync portal creation panicked")
+ }
+ }()
ctx = log.WithContext(ctx)
limit := wa.Main.Config.HistorySync.MaxInitialConversations
loginTS := wa.UserLogin.Metadata.(*waid.UserLoginMetadata).LoggedInAt
diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go
index 57f9113c..b3ddcf90 100644
--- a/pkg/msgconv/from-matrix.go
+++ b/pkg/msgconv/from-matrix.go
@@ -150,7 +150,7 @@ func (mc *MessageConverter) ToWhatsApp(
}
lid := parsedID.Sender
if lid.Server == types.DefaultUserServer {
- lid, err = client.Store.LIDs.GetLIDForPN(ctx, lid)
+ lid, err = getStore(client).LIDs.GetLIDForPN(ctx, lid)
if err != nil {
return nil, nil, fmt.Errorf("failed to get LID for PN: %w", err)
}
diff --git a/pkg/msgconv/from-whatsapp.go b/pkg/msgconv/from-whatsapp.go
index 6dac5a28..99d036f7 100644
--- a/pkg/msgconv/from-whatsapp.go
+++ b/pkg/msgconv/from-whatsapp.go
@@ -30,6 +30,7 @@ import (
"go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
+ "go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/types"
_ "golang.org/x/image/webp"
"maunium.net/go/mautrix/bridgev2"
@@ -277,10 +278,10 @@ func (mc *MessageConverter) ToMatrix(
var pn, lid types.JID
if pcp.Server == types.DefaultUserServer {
pn = pcp
- lid, _ = client.Store.LIDs.GetLIDForPN(ctx, pcp)
+ lid, _ = getStore(client).LIDs.GetLIDForPN(ctx, pcp)
} else if pcp.Server == types.HiddenUserServer {
lid = pcp
- pn, _ = client.Store.LIDs.GetPNForLID(ctx, pcp)
+ pn, _ = getStore(client).LIDs.GetPNForLID(ctx, pcp)
} else if pcp.Server == types.BotServer {
lid = pcp
}
@@ -322,3 +323,12 @@ func (mc *MessageConverter) ToMatrix(
return cm
}
+
+func getStore(cli *whatsmeow.Client) *store.Device {
+ if cli != nil {
+ if currentStore := cli.Store; currentStore != nil {
+ return currentStore
+ }
+ }
+ return store.NoopDevice
+}
diff --git a/pkg/msgconv/matrixpoll.go b/pkg/msgconv/matrixpoll.go
index a5f02ea9..5d5d0145 100644
--- a/pkg/msgconv/matrixpoll.go
+++ b/pkg/msgconv/matrixpoll.go
@@ -150,8 +150,8 @@ func MessageIDToInfo(ctx context.Context, client *whatsmeow.Client, parsedMsgID
chat := parsedMsgID.Chat
sender := parsedMsgID.Sender
if chat.Server == types.DefaultUserServer {
- chatLID, _ := client.Store.LIDs.GetLIDForPN(ctx, chat)
- senderLID, _ := client.Store.LIDs.GetLIDForPN(ctx, sender)
+ chatLID, _ := getStore(client).LIDs.GetLIDForPN(ctx, chat)
+ senderLID, _ := getStore(client).LIDs.GetLIDForPN(ctx, sender)
if !chatLID.IsEmpty() && !senderLID.IsEmpty() {
chat = chatLID
sender = senderLID
@@ -161,7 +161,7 @@ func MessageIDToInfo(ctx context.Context, client *whatsmeow.Client, parsedMsgID
MessageSource: types.MessageSource{
Chat: chat,
Sender: sender,
- IsFromMe: sender.User == client.Store.GetLID().User || sender.User == client.Store.GetJID().User,
+ IsFromMe: sender.User == getStore(client).GetLID().User || sender.User == getStore(client).GetJID().User,
IsGroup: chat.Server == types.GroupServer,
},
ID: parsedMsgID.ID,
diff --git a/pkg/msgconv/wa-poll.go b/pkg/msgconv/wa-poll.go
index 881a5fb0..2bcf671c 100644
--- a/pkg/msgconv/wa-poll.go
+++ b/pkg/msgconv/wa-poll.go
@@ -108,7 +108,7 @@ func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender
sender.Server = types.DefaultUserServer
}
} else if chat.Server == types.DefaultUserServer || chat.Server == types.HiddenUserServer || chat.Server == types.BotServer {
- if sender.User == client.Store.GetJID().User || sender.User == client.Store.GetLID().User {
+ if sender.User == getStore(client).GetJID().User || sender.User == getStore(client).GetLID().User {
// Message key is not from the sender, but message sender (containing key) is me,
// so message key sender is the other user in the DM
sender = chat
@@ -116,9 +116,9 @@ func KeyToMessageID(ctx context.Context, client *whatsmeow.Client, chat, sender
// Message key is not from the sender, but message sender (containing key) is not me,
// so message key sender is me
if chat.Server == types.HiddenUserServer {
- sender = client.Store.GetLID().ToNonAD()
+ sender = getStore(client).GetLID().ToNonAD()
} else {
- sender = client.Store.GetJID().ToNonAD()
+ sender = getStore(client).GetJID().ToNonAD()
}
}
} else {
From bcd77819e3f7655c953372f10191d6109f76e9e0 Mon Sep 17 00:00:00 2001
From: Gerardo Rodriguez
Date: Mon, 28 Sep 2026 12:21:56 +0000
Subject: [PATCH 130/203] handlewhatsapp: use proper profile for Muse and fix
message handling (#958)
Co-authored-by: Indent
---
go.mod | 4 +-
go.sum | 8 ++--
pkg/connector/events.go | 20 +++++++++
pkg/connector/handlewhatsapp.go | 6 +++
pkg/connector/muse.go | 80 +++++++++++++++++++++++++++++++++
pkg/msgconv/wa-rich-response.go | 29 +++++++-----
6 files changed, 129 insertions(+), 18 deletions(-)
create mode 100644 pkg/connector/muse.go
diff --git a/go.mod b/go.mod
index 6f12b2a5..dcae3bb8 100644
--- a/go.mod
+++ b/go.mod
@@ -13,13 +13,13 @@ 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-20260925162019-b3832c2bd1d1
+ go.mau.fi/whatsmeow v0.0.0-20260927171547-45cfce066cd2
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
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.20260921130557-78e83388b27e
+ maunium.net/go/mautrix v0.31.1-0.20260928113010-0d8772dfa579
)
require (
diff --git a/go.sum b/go.sum
index 7e657875..82983afe 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-20260925162019-b3832c2bd1d1 h1:iGMEoW98F/m73hSf4+EwV8l5yVfsZGFqvYRM7dAMWs4=
-go.mau.fi/whatsmeow v0.0.0-20260925162019-b3832c2bd1d1/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4=
+go.mau.fi/whatsmeow v0.0.0-20260927171547-45cfce066cd2 h1:MdSiBaMjvUIFbk8Cqf90CJU0JrRYXnvGKQNoM3ciaas=
+go.mau.fi/whatsmeow v0.0.0-20260927171547-45cfce066cd2/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=
@@ -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.20260921130557-78e83388b27e h1:ndauW8laSq7G105w9hPkTjwaOw5/f2z2SgMr9Yq6MKw=
-maunium.net/go/mautrix v0.31.1-0.20260921130557-78e83388b27e/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk=
+maunium.net/go/mautrix v0.31.1-0.20260928113010-0d8772dfa579 h1:CfIu/q0W0z8uLR9o6C0kcFQqFTTWUi2j9+Vd9yQV1xg=
+maunium.net/go/mautrix v0.31.1-0.20260928113010-0d8772dfa579/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk=
diff --git a/pkg/connector/events.go b/pkg/connector/events.go
index c23189d6..c5cdac85 100644
--- a/pkg/connector/events.go
+++ b/pkg/connector/events.go
@@ -201,6 +201,7 @@ func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Por
cm := evt.wa.Main.MsgConv.ToMatrix(
ctx, portal, evt.wa.Client, intent, editedMsg, evt.MsgEvent.RawMessage, &evt.Info, evt.isViewOnce(), false, previouslyConvertedPart,
)
+ evt.addMuseProfile(portal, cm)
if evt.isUndecryptableUpsertSubEvent && isFailedMedia(cm) {
evt.postHandle = func() {
evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), cm, false)
@@ -281,11 +282,30 @@ func (evt *WAMessageEvent) HandleExisting(ctx context.Context, portal *bridgev2.
return bridgev2.UpsertResult{}, nil
}
+func (evt *WAMessageEvent) addMuseProfile(portal *bridgev2.Portal, converted *bridgev2.ConvertedMessage) {
+ if evt.Info.Chat != types.MuseJID || evt.Info.IsFromMe || !portal.NameIsCustom {
+ return
+ }
+ profile := &event.BeeperPerMessageProfile{
+ ID: string(portal.MXID),
+ Displayname: portal.Name,
+ }
+ if portal.AvatarMXC != "" {
+ profile.AvatarURL = &portal.AvatarMXC
+ }
+ for _, part := range converted.Parts {
+ if part.Content != nil {
+ part.Content.BeeperPerMessageProfile = profile
+ }
+ }
+}
+
func (evt *WAMessageEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
evt.wa.EnqueuePortalResync(portal, false)
converted := evt.wa.Main.MsgConv.ToMatrix(
ctx, portal, evt.wa.Client, intent, evt.Message, evt.MsgEvent.RawMessage, &evt.Info, evt.isViewOnce(), false, nil,
)
+ evt.addMuseProfile(portal, converted)
if isFailedMedia(converted) {
evt.postHandle = func() {
evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), converted, false)
diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go
index dba4f1ee..f4010982 100644
--- a/pkg/connector/handlewhatsapp.go
+++ b/pkg/connector/handlewhatsapp.go
@@ -334,6 +334,12 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa
wa.Main.Bridge.Config.Backfill.Enabled {
wa.saveWAHistorySyncNotification(ctx, evt.Message.ProtocolMessage.HistorySyncNotification)
}
+ if protocol := evt.Message.GetProtocolMessage(); evt.Info.Chat == types.MuseJID && protocol.GetType() == waE2E.ProtocolMessage_AI_METADATA_OPERATION {
+ if sync := protocol.GetAiMetadataOperation().GetHatchMetadataSync(); sync != nil {
+ return wa.handleMuseMetadata(ctx, sync.GetData())
+ }
+ return
+ }
if parsedMessageType == "ignore" {
return
} else if strings.HasPrefix(parsedMessageType, "unknown_protocol_") {
diff --git a/pkg/connector/muse.go b/pkg/connector/muse.go
new file mode 100644
index 00000000..73c4206a
--- /dev/null
+++ b/pkg/connector/muse.go
@@ -0,0 +1,80 @@
+package connector
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+
+ "github.com/rs/zerolog"
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/types"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/bridgev2/simplevent"
+)
+
+type museImage struct {
+ DirectPath string `json:"direct_path"`
+ EncHash []byte `json:"file_enc_sha256_b64"`
+ Hash []byte `json:"file_sha256_b64"`
+ Key []byte `json:"media_key_b64"`
+ Length int `json:"file_length"`
+}
+
+func (wa *WhatsAppClient) handleMuseMetadata(ctx context.Context, data []byte) bool {
+ var msg struct {
+ Type string `json:"type"`
+ Payload struct {
+ Event string `json:"event"`
+ Payload struct {
+ Name string `json:"name"`
+ Avatar struct {
+ Image museImage `json:"secure_image"`
+ } `json:"avatar"`
+ } `json:"payload"`
+ } `json:"payload"`
+ }
+ if err := json.Unmarshal(data, &msg); err != nil {
+ zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to decode Muse metadata")
+ return true
+ }
+ if msg.Type != "event" || msg.Payload.Event != "identity.updated" || msg.Payload.Payload.Name == "" {
+ return true
+ }
+ name := msg.Payload.Payload.Name
+ return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
+ EventMeta: simplevent.EventMeta{
+ Type: bridgev2.RemoteEventChatInfoChange,
+ PortalKey: wa.makeWAPortalKey(types.MuseJID),
+ CreatePortal: true,
+ },
+ ChatInfoChange: &bridgev2.ChatInfoChange{
+ ChatInfo: &bridgev2.ChatInfo{
+ Name: &name,
+ Avatar: wa.museAvatar(msg.Payload.Payload.Avatar.Image),
+ ExcludeChangesFromTimeline: true,
+ },
+ },
+ }).Success
+}
+
+func (wa *WhatsAppClient) museAvatar(image museImage) *bridgev2.Avatar {
+ if image.DirectPath == "" || image.Length <= 0 || image.Length > 5<<20 {
+ return nil
+ }
+ if len(image.EncHash) != 32 || len(image.Hash) != 32 || len(image.Key) != 32 {
+ return nil
+ }
+ return &bridgev2.Avatar{
+ ID: networkid.AvatarID(base64.StdEncoding.EncodeToString(image.Hash)),
+ Get: func(ctx context.Context) ([]byte, error) {
+ data, err := wa.Client.DownloadMediaWithPath(ctx, image.DirectPath, image.EncHash, image.Hash, image.Key, whatsmeow.MediaImage, "image", false)
+ if err != nil {
+ zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to download Muse avatar")
+ return nil, fmt.Errorf("failed to download Muse avatar: %w", err)
+ }
+ return data, nil
+ },
+ }
+}
diff --git a/pkg/msgconv/wa-rich-response.go b/pkg/msgconv/wa-rich-response.go
index d2dab516..41ac9ab9 100644
--- a/pkg/msgconv/wa-rich-response.go
+++ b/pkg/msgconv/wa-rich-response.go
@@ -26,6 +26,7 @@ import (
"github.com/rs/zerolog"
"github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
"go.mau.fi/whatsmeow/types/richresponse"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
@@ -42,10 +43,20 @@ func (mc *MessageConverter) convertUnifiedRichResponseMessage(ctx context.Contex
}
var rrMsg richresponse.RichResponse
err := json.Unmarshal(data, &rrMsg)
- if err != nil || len(rrMsg.Sections) == 0 {
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to unmarshal unified rich response message")
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to unmarshal unified rich response message")
+ rrMsg.Sections = nil
+ }
+ var htmlBuf strings.Builder
+ for _, mv := range rrMsg.Sections {
+ if mv.Model == nil {
+ continue
}
+ for _, p := range mv.Model.GetPrimitives() {
+ mc.convertRichResponsePrimitive(ctx, p, &htmlBuf)
+ }
+ }
+ if htmlBuf.Len() == 0 {
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
@@ -56,12 +67,6 @@ func (mc *MessageConverter) convertUnifiedRichResponseMessage(ctx context.Contex
}
}
extra["fi.mau.whatsapp.rich_response_id"] = rrMsg.ResponseID
- var htmlBuf strings.Builder
- for _, mv := range rrMsg.Sections {
- for _, p := range mv.Model.GetPrimitives() {
- mc.convertRichResponsePrimitive(ctx, p, &htmlBuf)
- }
- }
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: new(format.HTMLToContent(htmlBuf.String())),
@@ -69,7 +74,7 @@ func (mc *MessageConverter) convertUnifiedRichResponseMessage(ctx context.Contex
}
}
-var mdRender = goldmark.New(format.Extensions, format.HTMLOptions, goldmark.WithExtensions(mdext.EscapeHTML))
+var mdRender = goldmark.New(format.HTMLOptions, goldmark.WithExtensions(extension.Strikethrough, extension.Table, mdext.EscapeHTML, mdext.ShortEmphasis, mdext.Math))
func (mc *MessageConverter) convertRichMarkdownText(ctx context.Context, text *richresponse.GenAIMarkdownTextUXPrimitive, buf *strings.Builder) error {
for _, ent := range text.InlineEntities {
@@ -113,7 +118,7 @@ func (mc *MessageConverter) convertRichResponsePrimitive(ctx context.Context, pr
buf.WriteString("")
}
for _, part := range p.CodeBlocks {
- buf.WriteString(part.Content)
+ buf.WriteString(html.EscapeString(part.Content))
}
buf.WriteString("
")
case *richresponse.GenAIMarkdownTextUXPrimitive:
@@ -122,7 +127,7 @@ func (mc *MessageConverter) convertRichResponsePrimitive(ctx context.Context, pr
buf.WriteString("")
for _, row := range p.Rows {
buf.WriteString("")
- cellType := "tr"
+ cellType := "td"
if row.IsHeader {
cellType = "th"
}
From 4fc155c597a2407e015a5279c50de6f57c668cc9 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Mon, 28 Sep 2026 17:05:47 +0300
Subject: [PATCH 131/203] 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 dcae3bb8..c5b87b24 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,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-20260927171547-45cfce066cd2
+ go.mau.fi/whatsmeow v0.0.0-20260928140511-35f522c88ce3
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 82983afe..6b66cf99 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-20260927171547-45cfce066cd2 h1:MdSiBaMjvUIFbk8Cqf90CJU0JrRYXnvGKQNoM3ciaas=
-go.mau.fi/whatsmeow v0.0.0-20260927171547-45cfce066cd2/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4=
+go.mau.fi/whatsmeow v0.0.0-20260928140511-35f522c88ce3 h1:ix7+8nGKloIdLhGeggvKllb5N/J6hfwImBy/xzOvrgY=
+go.mau.fi/whatsmeow v0.0.0-20260928140511-35f522c88ce3/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 2ffea1248ab9ddcdb83712d2a7e85d277d51925e Mon Sep 17 00:00:00 2001
From: Gerardo Rodriguez
Date: Tue, 29 Sep 2026 19:15:59 +0000
Subject: [PATCH 132/203] client: automatically request muse key and profile if
missing (#960)
---
go.mod | 6 +-
go.sum | 8 +--
pkg/connector/chatinfo.go | 3 +
pkg/connector/client.go | 8 +++
pkg/connector/handlewhatsapp.go | 6 ++
pkg/connector/login.go | 1 +
pkg/connector/muse.go | 106 +++++++++++++++++++++++++++++++-
pkg/waid/dbmeta.go | 1 +
8 files changed, 131 insertions(+), 8 deletions(-)
diff --git a/go.mod b/go.mod
index c5b87b24..38c4495a 100644
--- a/go.mod
+++ b/go.mod
@@ -7,19 +7,20 @@ toolchain go1.27.1
tool go.mau.fi/util/cmd/maubuild
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/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-20260928140511-35f522c88ce3
+ go.mau.fi/whatsmeow v0.0.0-20260929112325-8b41cfe6d9c4
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
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.20260928113010-0d8772dfa579
+ maunium.net/go/mautrix v0.31.1-0.20260929190113-4aac2bbf2888
)
require (
@@ -28,7 +29,6 @@ require (
github.com/coder/websocket v1.8.15 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
diff --git a/go.sum b/go.sum
index 6b66cf99..954ffc14 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-20260928140511-35f522c88ce3 h1:ix7+8nGKloIdLhGeggvKllb5N/J6hfwImBy/xzOvrgY=
-go.mau.fi/whatsmeow v0.0.0-20260928140511-35f522c88ce3/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4=
+go.mau.fi/whatsmeow v0.0.0-20260929112325-8b41cfe6d9c4 h1:0PXBB/t9JjRLCyv8Dp5K1OyE+ut/6VgKZqGUsaAaPNk=
+go.mau.fi/whatsmeow v0.0.0-20260929112325-8b41cfe6d9c4/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=
@@ -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.20260928113010-0d8772dfa579 h1:CfIu/q0W0z8uLR9o6C0kcFQqFTTWUi2j9+Vd9yQV1xg=
-maunium.net/go/mautrix v0.31.1-0.20260928113010-0d8772dfa579/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk=
+maunium.net/go/mautrix v0.31.1-0.20260929190113-4aac2bbf2888 h1:sC0fpamjHE5Q2nqy9frxv30rHcX53xPg0FRZ3C2IVoU=
+maunium.net/go/mautrix v0.31.1-0.20260929190113-4aac2bbf2888/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk=
diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go
index 267200ce..7635e860 100644
--- a/pkg/connector/chatinfo.go
+++ b/pkg/connector/chatinfo.go
@@ -35,6 +35,9 @@ func (wa *WhatsAppClient) getChatInfo(ctx context.Context, portalJID types.JID,
switch portalJID.Server {
case types.DefaultUserServer, types.HiddenUserServer, types.BotServer:
wrapped = wa.wrapDMInfo(ctx, portalJID)
+ if portalJID == types.MuseJID {
+ go wa.requestMuseProfile(ctx)
+ }
case types.BroadcastServer:
if portalJID == types.StatusBroadcastJID {
wrapped = wa.wrapStatusBroadcastInfo(ctx)
diff --git a/pkg/connector/client.go b/pkg/connector/client.go
index 2287fead..9a54fcd9 100644
--- a/pkg/connector/client.go
+++ b/pkg/connector/client.go
@@ -127,6 +127,10 @@ type WhatsAppClient struct {
appStateRecoveryLock sync.Mutex
appStateFullSyncAttempted map[appstate.WAPatchName]time.Time
+
+ wasaResyncLock sync.Mutex
+ museProfileLock sync.Mutex
+ lastMuseProfileRequest time.Time
}
var (
@@ -460,6 +464,10 @@ func (wa *WhatsAppClient) HandleMatrixViewingChat(ctx context.Context, msg *brid
}
}
+ if msg.Portal != nil && msg.Portal.ID == waid.MakePortalID(types.MuseJID) {
+ go wa.requestMuseProfile(ctx)
+ }
+
if msg.Portal == nil || msg.Portal.Metadata.(*waid.PortalMetadata).LastSync.Add(5*time.Minute).After(time.Now()) {
// If we resynced this portal within the last 5 minutes, don't do it again
return nil
diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go
index f4010982..d320ad1f 100644
--- a/pkg/connector/handlewhatsapp.go
+++ b/pkg/connector/handlewhatsapp.go
@@ -174,6 +174,12 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
case *events.Connected:
log.Debug().Msg("Connected to WhatsApp socket")
wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected})
+ if wa.offlineSyncWaiter.Load() == nil {
+ go func() {
+ wa.resyncWASARootSecrets(ctx)
+ wa.requestMuseProfile(ctx)
+ }()
+ }
if len(wa.GetStore().PushName) > 0 {
go func() {
err := wa.updatePresence(ctx, types.PresenceUnavailable)
diff --git a/pkg/connector/login.go b/pkg/connector/login.go
index fa2c4792..e5f7cd48 100644
--- a/pkg/connector/login.go
+++ b/pkg/connector/login.go
@@ -402,6 +402,7 @@ func (wl *WALogin) onLoginComplete(ctx context.Context) (*bridgev2.LoginStep, er
Timezone: wl.Timezone,
HistorySyncPortalsNeedCreating: true,
+ WASAResynced: true,
},
}, &bridgev2.NewLoginParams{
DeleteOnConflict: true,
diff --git a/pkg/connector/muse.go b/pkg/connector/muse.go
index 73c4206a..94a25d90 100644
--- a/pkg/connector/muse.go
+++ b/pkg/connector/muse.go
@@ -5,13 +5,24 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
+ "runtime/debug"
+ "time"
+ "github.com/google/uuid"
"github.com/rs/zerolog"
+ "go.mau.fi/util/exerrors"
"go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/appstate"
+ "go.mau.fi/whatsmeow/proto/waAICommon"
+ "go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
+ "google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
+
+ "go.mau.fi/mautrix-whatsapp/pkg/waid"
)
type museImage struct {
@@ -22,6 +33,93 @@ type museImage struct {
Length int `json:"file_length"`
}
+func (wa *WhatsAppClient) resyncWASARootSecrets(ctx context.Context) {
+ defer func() {
+ v := recover()
+ if v != nil {
+ zerolog.Ctx(ctx).Err(exerrors.RecoverToError(v)).
+ Bytes("stack", debug.Stack()).
+ Msg("Error resyncing WASA root secrets")
+ }
+ }()
+ wa.wasaResyncLock.Lock()
+ defer wa.wasaResyncLock.Unlock()
+ if wa.offlineSyncWaiter.Load() != nil || !wa.Client.IsConnected() {
+ return
+ }
+ meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
+ if meta.WASAResynced {
+ return
+ }
+ log := zerolog.Ctx(ctx)
+ log.Info().Msg("Resyncing WASA root secrets for existing login")
+ if err := wa.Client.FetchAppState(ctx, appstate.WAPatchRegularHigh, true, false); err != nil {
+ log.Err(err).Msg("Failed to resync WASA root secrets")
+ return
+ }
+ meta.WASAResynced = true
+ if err := wa.UserLogin.Save(ctx); err != nil {
+ meta.WASAResynced = false
+ log.Err(err).Msg("Failed to save WASA resync completion")
+ return
+ }
+ log.Info().Msg("Completed WASA root secret resync")
+}
+
+func (wa *WhatsAppClient) requestMuseProfile(ctx context.Context) {
+ defer func() {
+ v := recover()
+ if v != nil {
+ zerolog.Ctx(ctx).Err(exerrors.RecoverToError(v)).
+ Bytes("stack", debug.Stack()).
+ Msg("Error requesting Muse profile")
+ }
+ }()
+ if !wa.museProfileLock.TryLock() {
+ return
+ }
+ defer wa.museProfileLock.Unlock()
+ if wa.offlineSyncWaiter.Load() != nil || !wa.Client.IsConnected() || time.Since(wa.lastMuseProfileRequest) < 5*time.Minute {
+ return
+ }
+ portal, err := wa.Main.Bridge.DB.Portal.GetByKey(ctx, wa.makeWAPortalKey(types.MuseJID))
+ if err != nil {
+ zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to check Muse profile")
+ return
+ } else if portal != nil && portal.NameIsCustom && portal.Name != "" && portal.NameSet && museAvatarReady(portal) {
+ return
+ }
+ rootID, err := wa.GetStore().ChatSettings.GetWASARootSecretID(ctx, types.MuseJID)
+ if err != nil {
+ zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to check Muse root secret")
+ return
+ } else if rootID == "" {
+ return
+ }
+ wa.lastMuseProfileRequest = time.Now()
+ _, err = wa.Client.SendMessage(ctx, types.MuseJID, &waE2E.Message{
+ ProtocolMessage: &waE2E.ProtocolMessage{
+ Type: waE2E.ProtocolMessage_AI_METADATA_OPERATION.Enum(),
+ AiMetadataOperation: &waAICommon.AIMetadataOperation{
+ HatchMetadataSync: &waAICommon.HatchMetadataSync{
+ Data: []byte(`{"version":1,"type":"req","payload":{"method":"channel.bootstrap","params":{"sections":["agent.status","identity.updated","hitl.snapshot"]}}}`),
+ TimestampMS: proto.Int64(time.Now().UnixMilli()),
+ RequestID: proto.String(uuid.NewString()),
+ },
+ },
+ },
+ })
+ if err != nil {
+ zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to request Muse profile")
+ }
+}
+
+func museAvatarReady(portal *database.Portal) bool {
+ expectedHash, err := base64.StdEncoding.DecodeString(string(portal.AvatarID))
+ return err != nil || len(expectedHash) != 32 ||
+ (portal.AvatarMXC != "" && portal.AvatarHash == [32]byte(expectedHash))
+}
+
func (wa *WhatsAppClient) handleMuseMetadata(ctx context.Context, data []byte) bool {
var msg struct {
Type string `json:"type"`
@@ -43,16 +141,22 @@ func (wa *WhatsAppClient) handleMuseMetadata(ctx context.Context, data []byte) b
return true
}
name := msg.Payload.Payload.Name
+ avatar := wa.museAvatar(msg.Payload.Payload.Avatar.Image)
return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventChatInfoChange,
PortalKey: wa.makeWAPortalKey(types.MuseJID),
CreatePortal: true,
+ PreHandleFunc: func(_ context.Context, portal *bridgev2.Portal) {
+ if avatar != nil && !museAvatarReady(portal.Portal) {
+ portal.AvatarSet = false
+ }
+ },
},
ChatInfoChange: &bridgev2.ChatInfoChange{
ChatInfo: &bridgev2.ChatInfo{
Name: &name,
- Avatar: wa.museAvatar(msg.Payload.Payload.Avatar.Image),
+ Avatar: avatar,
ExcludeChangesFromTimeline: true,
},
},
diff --git a/pkg/waid/dbmeta.go b/pkg/waid/dbmeta.go
index e2b60f73..6e018f63 100644
--- a/pkg/waid/dbmeta.go
+++ b/pkg/waid/dbmeta.go
@@ -40,6 +40,7 @@ type UserLoginMetadata struct {
LoggedInAt jsontime.Unix `json:"logged_in_at,omitzero"`
AppStateRecoveryAttempted map[appstate.WAPatchName]time.Time `json:"app_state_recovery_attempted,omitempty"`
+ WASAResynced bool `json:"wasa_resynced,omitzero"`
HistorySyncPortalsNeedCreating bool `json:"history_sync_portals_need_creating,omitzero"`
ReachoutTimelockUntil time.Time `json:"reachout_timelock_until,omitzero"`
From 9f436b26dbb27f93cf1351a403d056431edbe061 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Tue, 29 Sep 2026 22:17:42 +0300
Subject: [PATCH 133/203] muse: log when doing initial profile sync
---
pkg/connector/muse.go | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/pkg/connector/muse.go b/pkg/connector/muse.go
index 94a25d90..7c62144c 100644
--- a/pkg/connector/muse.go
+++ b/pkg/connector/muse.go
@@ -79,7 +79,7 @@ func (wa *WhatsAppClient) requestMuseProfile(ctx context.Context) {
return
}
defer wa.museProfileLock.Unlock()
- if wa.offlineSyncWaiter.Load() != nil || !wa.Client.IsConnected() || time.Since(wa.lastMuseProfileRequest) < 5*time.Minute {
+ if wa.offlineSyncWaiter.Load() != nil || !wa.Client.IsConnected() || time.Since(wa.lastMuseProfileRequest) < 15*time.Minute {
return
}
portal, err := wa.Main.Bridge.DB.Portal.GetByKey(ctx, wa.makeWAPortalKey(types.MuseJID))
@@ -96,6 +96,7 @@ func (wa *WhatsAppClient) requestMuseProfile(ctx context.Context) {
} else if rootID == "" {
return
}
+ zerolog.Ctx(ctx).Info().Msg("Sending request to sync Muse profile")
wa.lastMuseProfileRequest = time.Now()
_, err = wa.Client.SendMessage(ctx, types.MuseJID, &waE2E.Message{
ProtocolMessage: &waE2E.ProtocolMessage{
From e1f141f4478a462e8de1b082e73d88e017f87bbc Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Wed, 30 Sep 2026 19:00:20 +0300
Subject: [PATCH 134/203] handlewhatsapp: log inner message type in edits
---
pkg/connector/events.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/pkg/connector/events.go b/pkg/connector/events.go
index c5cdac85..bd9f9bff 100644
--- a/pkg/connector/events.go
+++ b/pkg/connector/events.go
@@ -136,6 +136,9 @@ func (evt *WAMessageEvent) AddLogContext(c zerolog.Context) zerolog.Context {
if targetMsg := evt.GetTargetMessage(); targetMsg != "" {
c = c.Str("target_message_id", string(targetMsg))
}
+ if evt.parsedMessageType == "edit" {
+ c = c.Str("edit_inner_message_type", getMessageType(evt.Message.GetProtocolMessage().GetEditedMessage()))
+ }
return evt.MessageInfoWrapper.AddLogContext(c).Str("parsed_message_type", evt.parsedMessageType)
}
From cafeca69796379abf80df1df9f6b6fdf97c21c25 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Wed, 30 Sep 2026 19:20:36 +0300
Subject: [PATCH 135/203] handlewhatsapp: unwrap some message types in edits
---
pkg/connector/events.go | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/pkg/connector/events.go b/pkg/connector/events.go
index bd9f9bff..bc113071 100644
--- a/pkg/connector/events.go
+++ b/pkg/connector/events.go
@@ -192,12 +192,18 @@ func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Por
cacheMessage = evt.GetID()
} else {
editedMsg = evt.Message.GetProtocolMessage().GetEditedMessage()
- previouslyConvertedPart = evt.wa.Main.GetMediaEditCache(portal, targetMessage)
+ if editedMsg.GetLottieStickerMessage().GetMessage() != nil {
+ editedMsg = editedMsg.GetLottieStickerMessage().GetMessage()
+ }
+ if editedMsg.GetDocumentWithCaptionMessage().GetMessage() != nil {
+ editedMsg = editedMsg.GetDocumentWithCaptionMessage().GetMessage()
+ }
meta := existing[0].Metadata.(*waid.MessageMetadata)
if slices.Contains(meta.Edits, evt.Info.ID) {
return nil, fmt.Errorf("%w: edit already handled", bridgev2.ErrIgnoringRemoteEvent)
}
meta.Edits = append(meta.Edits, evt.Info.ID)
+ previouslyConvertedPart = evt.wa.Main.GetMediaEditCache(portal, targetMessage)
}
ctx = context.WithValue(ctx, msgconv.ContextKeyEditTargetID, evt.Message.GetProtocolMessage().GetKey().GetID())
From c84643e70ad92a62e4e7f7096c5a56ec7fdfa5f8 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Wed, 30 Sep 2026 19:20:58 +0300
Subject: [PATCH 136/203] handlewhatsapp: try to fetch message from server for
caption edit
---
pkg/connector/events.go | 24 ++++++++++++++++++++++++
pkg/connector/wamsgtype.go | 10 ++++++++++
2 files changed, 34 insertions(+)
diff --git a/pkg/connector/events.go b/pkg/connector/events.go
index bc113071..bd328c59 100644
--- a/pkg/connector/events.go
+++ b/pkg/connector/events.go
@@ -204,6 +204,30 @@ func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Por
}
meta.Edits = append(meta.Edits, evt.Info.ID)
previouslyConvertedPart = evt.wa.Main.GetMediaEditCache(portal, targetMessage)
+ if previouslyConvertedPart == nil && needsPreviousEditPart(editedMsg) {
+ editedEvent, err := evt.wa.Main.Bridge.Bot.GetEvent(ctx, portal.MXID, existing[0].MXID)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to fetch existing event for edit")
+ } else if editedEvent != nil && editedEvent.Content.AsMessage().MsgType != "" {
+ zerolog.Ctx(ctx).Debug().Msg("Fetched existing edit from server for caption edit")
+ copiedExtra := make(map[string]any)
+ for _, key := range []string{"info", msgconv.FailedMediaField} {
+ val, ok := editedEvent.Content.Raw[key]
+ if ok {
+ copiedExtra[key] = val
+ }
+ }
+ previouslyConvertedPart = &bridgev2.ConvertedMessagePart{
+ ID: existing[0].PartID,
+ Type: editedEvent.Type,
+ Content: editedEvent.Content.AsMessage(),
+ Extra: copiedExtra,
+ DBMetadata: meta,
+ }
+ } else {
+ zerolog.Ctx(ctx).Debug().Msg("Didn't find existing edit on server for caption edit")
+ }
+ }
}
ctx = context.WithValue(ctx, msgconv.ContextKeyEditTargetID, evt.Message.GetProtocolMessage().GetKey().GetID())
diff --git a/pkg/connector/wamsgtype.go b/pkg/connector/wamsgtype.go
index 0a29b829..efdf679d 100644
--- a/pkg/connector/wamsgtype.go
+++ b/pkg/connector/wamsgtype.go
@@ -159,3 +159,13 @@ func getMessageType(waMsg *waE2E.Message) string {
return "unknown"
}
}
+
+func needsPreviousEditPart(waMsg *waE2E.Message) bool {
+ switch {
+ case waMsg.ImageMessage != nil, waMsg.StickerMessage != nil, waMsg.VideoMessage != nil,
+ waMsg.PtvMessage != nil, waMsg.AudioMessage != nil, waMsg.DocumentMessage != nil:
+ return true
+ default:
+ return false
+ }
+}
From a0325e76df3341136ab344dd3bd8401710117e42 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Wed, 30 Sep 2026 19:34:14 +0300
Subject: [PATCH 137/203] msgconv/from-whatsapp: don't create direct media url
for empty paths
---
pkg/msgconv/wa-media.go | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/pkg/msgconv/wa-media.go b/pkg/msgconv/wa-media.go
index 99648009..20a1a142 100644
--- a/pkg/msgconv/wa-media.go
+++ b/pkg/msgconv/wa-media.go
@@ -100,13 +100,17 @@ func (mc *MessageConverter) convertMediaMessage(
if err != nil {
panic(err)
}
- part = &bridgev2.ConvertedMessagePart{
- Type: preparedMedia.Type,
- Content: preparedMedia.MessageEventContent,
- Extra: preparedMedia.Extra,
- DBMetadata: &waid.MessageMetadata{
- DirectMediaMeta: directMediaMeta,
- },
+ if mediaKeys.DirectPath == "" {
+ part = mc.makeMediaFailure(ctx, preparedMedia, mediaKeys, whatsmeow.ErrNoURLPresent)
+ } else {
+ part = &bridgev2.ConvertedMessagePart{
+ Type: preparedMedia.Type,
+ Content: preparedMedia.MessageEventContent,
+ Extra: preparedMedia.Extra,
+ DBMetadata: &waid.MessageMetadata{
+ DirectMediaMeta: directMediaMeta,
+ },
+ }
}
} else if err := mc.reuploadWhatsAppAttachment(ctx, msg, preparedMedia); err != nil {
part = mc.makeMediaFailure(ctx, preparedMedia, mediaKeys, err)
From d97b19124562944386bc1776fafaef98ac603b91 Mon Sep 17 00:00:00 2001
From: Nick Mills-Barrett
Date: Tue, 6 Oct 2026 10:38:35 +0100
Subject: [PATCH 138/203] backfill: prevent panic when converting history sync
messages (#955)
---
pkg/connector/backfill.go | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go
index 50a0d4c2..85f4499b 100644
--- a/pkg/connector/backfill.go
+++ b/pkg/connector/backfill.go
@@ -628,7 +628,8 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
messages []*waWeb.WebMessageInfo,
explodeOnError bool,
) (*bridgev2.FetchMessagesResponse, error) {
- if wa.Client == nil {
+ cli := wa.Client
+ if cli == nil {
return nil, bridgev2.ErrNotLoggedIn
}
oldestTS := messages[len(messages)-1].GetMessageTimestamp()
@@ -637,7 +638,7 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
var mediaRequests []*wadb.MediaRequest
dups := make(exmaps.Set[networkid.MessageID])
for i, msg := range messages {
- evt, err := wa.Client.ParseWebMessage(portalJID, msg)
+ evt, err := cli.ParseWebMessage(portalJID, msg)
if err != nil {
if explodeOnError {
// This should never happen because the info is already parsed once before being stored in the database
@@ -664,7 +665,7 @@ func (wa *WhatsAppClient) convertHistorySyncMessages(
}
isViewOnce := evt.IsViewOnce || evt.IsViewOnceV2 || evt.IsViewOnceV2Extension
converted, mediaReq := wa.convertHistorySyncMessage(
- ctx, portal, &evt.Info, evt.Message, evt.RawMessage, isViewOnce, msg.Reactions,
+ ctx, cli, portal, &evt.Info, evt.Message, evt.RawMessage, isViewOnce, msg.Reactions,
)
// This is a hack to remove duplicates where the same message is inserted with both the LID and phone number sender
// TODO prevent those being inserted in the first place instead of hacking around it here
@@ -805,7 +806,7 @@ func (wa *WhatsAppClient) handleOnDemandHistorySync(ctx context.Context, blob *w
}
func (wa *WhatsAppClient) convertHistorySyncMessage(
- ctx context.Context, portal *bridgev2.Portal, info *types.MessageInfo, msg, rawMsg *waE2E.Message, isViewOnce bool, reactions []*waWeb.Reaction,
+ ctx context.Context, cli *whatsmeow.Client, portal *bridgev2.Portal, info *types.MessageInfo, msg, rawMsg *waE2E.Message, isViewOnce bool, reactions []*waWeb.Reaction,
) (*bridgev2.BackfillMessage, *wadb.MediaRequest) {
// New messages turn these into edits, but in backfill we only have the last version,
// so no need to do the edit thing. Instead, just unwrap the message.
@@ -816,7 +817,7 @@ func (wa *WhatsAppClient) convertHistorySyncMessage(
intent := wa.Main.Bridge.Bot
msgID := waid.MakeMessageIDWithAltSender(info.Chat, info.Sender, info.SenderAlt, info.ID)
wrapped := &bridgev2.BackfillMessage{
- ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, intent, msg, rawMsg, info, isViewOnce, true, nil),
+ ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, cli, intent, msg, rawMsg, info, isViewOnce, true, nil),
Sender: wa.makeEventSender(ctx, pickLID(info.Sender, info.SenderAlt)),
ID: msgID,
TxnID: networkid.TransactionID(msgID),
From 06b42e140cd48284b3220c4e1b5796a6b9d1ae69 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Tue, 6 Oct 2026 12:52:01 +0300
Subject: [PATCH 139/203] muse: mark resync as done on mismatching lthash
---
pkg/connector/muse.go | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/pkg/connector/muse.go b/pkg/connector/muse.go
index 7c62144c..ee6b04d9 100644
--- a/pkg/connector/muse.go
+++ b/pkg/connector/muse.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
+ "errors"
"fmt"
"runtime/debug"
"time"
@@ -53,9 +54,15 @@ func (wa *WhatsAppClient) resyncWASARootSecrets(ctx context.Context) {
}
log := zerolog.Ctx(ctx)
log.Info().Msg("Resyncing WASA root secrets for existing login")
+ success := true
if err := wa.Client.FetchAppState(ctx, appstate.WAPatchRegularHigh, true, false); err != nil {
log.Err(err).Msg("Failed to resync WASA root secrets")
- return
+ // 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 {
+ return
+ }
+ success = false
}
meta.WASAResynced = true
if err := wa.UserLogin.Save(ctx); err != nil {
@@ -63,7 +70,11 @@ func (wa *WhatsAppClient) resyncWASARootSecrets(ctx context.Context) {
log.Err(err).Msg("Failed to save WASA resync completion")
return
}
- log.Info().Msg("Completed WASA root secret resync")
+ if success {
+ log.Info().Msg("Completed WASA root secret resync")
+ } else {
+ log.Info().Msg("Marked WASA root resync as done despite errors")
+ }
}
func (wa *WhatsAppClient) requestMuseProfile(ctx context.Context) {
From a34259020e60cf6e16cf5002e5d885ee03a3dd15 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Tue, 6 Oct 2026 15:22:21 +0300
Subject: [PATCH 140/203] handle*: add support for blocking DMs
---
go.mod | 4 +-
go.sum | 8 +--
pkg/connector/capabilities.go | 15 +++++-
pkg/connector/chatinfo.go | 21 ++++++++
pkg/connector/client.go | 1 +
pkg/connector/handlematrix.go | 48 +++++++++++++++++
pkg/connector/handlewhatsapp.go | 92 +++++++++++++++++++++++++++++++++
pkg/waid/dbmeta.go | 2 +
8 files changed, 183 insertions(+), 8 deletions(-)
diff --git a/go.mod b/go.mod
index 38c4495a..b6398d42 100644
--- a/go.mod
+++ b/go.mod
@@ -14,13 +14,13 @@ 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-20260929112325-8b41cfe6d9c4
+ go.mau.fi/whatsmeow v0.0.0-20261006124319-9399289b022b
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
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.20260929190113-4aac2bbf2888
+ maunium.net/go/mautrix v0.31.1-0.20261005174338-57e1ea5dd74d
)
require (
diff --git a/go.sum b/go.sum
index 954ffc14..5854082c 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-20260929112325-8b41cfe6d9c4 h1:0PXBB/t9JjRLCyv8Dp5K1OyE+ut/6VgKZqGUsaAaPNk=
-go.mau.fi/whatsmeow v0.0.0-20260929112325-8b41cfe6d9c4/go.mod h1:7G7AeRACrC8Se+01+SQbdOp2J/Ce0DUMGxTKFKfRHW4=
+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/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=
@@ -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.20260929190113-4aac2bbf2888 h1:sC0fpamjHE5Q2nqy9frxv30rHcX53xPg0FRZ3C2IVoU=
-maunium.net/go/mautrix v0.31.1-0.20260929190113-4aac2bbf2888/go.mod h1:t97J+6KmbmeoepWOunBgpu1xIvuBpI36ebBqIbYT8Jk=
+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=
diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go
index 152e7ad6..b991893b 100644
--- a/pkg/connector/capabilities.go
+++ b/pkg/connector/capabilities.go
@@ -52,7 +52,7 @@ func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilit
}
func (wa *WhatsAppConnector) GetBridgeInfoVersion() (info, caps int) {
- return 1, 9
+ return 1, 10
}
const WAMaxFileSize = 2000 * 1024 * 1024
@@ -67,7 +67,7 @@ func supportedIfFFmpeg() event.CapabilitySupportLevel {
}
func capID() string {
- base := "fi.mau.whatsapp.capabilities.2026_07_22"
+ base := "fi.mau.whatsapp.capabilities.2026_10_01"
if ffmpeg.Supported() {
return base + "+ffmpeg"
}
@@ -198,6 +198,7 @@ var whatsappCaps = &event.RoomFeatures{
var whatsappDMCaps *event.RoomFeatures
var whatsappCAGCaps *event.RoomFeatures
+var whatsappNoteToSelfCaps *event.RoomFeatures
func init() {
whatsappDMCaps = ptr.Clone(whatsappCaps)
@@ -205,7 +206,13 @@ func init() {
whatsappDMCaps.State = event.StateFeatureMap{
event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported},
}
+ whatsappDMCaps.BlockUser = true
whatsappDMCaps.MemberActions = nil
+
+ whatsappNoteToSelfCaps = ptr.Clone(whatsappDMCaps)
+ whatsappNoteToSelfCaps.ID = capID() + "+self"
+ whatsappNoteToSelfCaps.BlockUser = false
+
whatsappCAGCaps = ptr.Clone(whatsappCaps)
whatsappCAGCaps.ID = capID() + "+cag"
whatsappCAGCaps.Reply = event.CapLevelUnsupported
@@ -216,6 +223,10 @@ func (wa *WhatsAppClient) GetCapabilities(ctx context.Context, portal *bridgev2.
if portal.Metadata.(*waid.PortalMetadata).CommunityAnnouncementGroup {
return whatsappCAGCaps
} else if portal.RoomType == database.RoomTypeDM {
+ jid, err := waid.ParsePortalID(portal.ID)
+ if err == nil && wa.IsOwnJID(jid) {
+ return whatsappNoteToSelfCaps
+ }
return whatsappDMCaps
}
return whatsappCaps
diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go
index 7635e860..3984b5f9 100644
--- a/pkg/connector/chatinfo.go
+++ b/pkg/connector/chatinfo.go
@@ -185,6 +185,11 @@ func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridge
},
ExcludeChangesFromTimeline: true,
}
+ var err error
+ info.UserBlocked, err = wa.isUserBlocked(ctx, jid)
+ if err != nil {
+ zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get blocked state")
+ }
if jid.Server == types.BotServer {
info.Topic = ptr.Ptr(BotChatTopic)
}
@@ -571,3 +576,19 @@ func (wa *WhatsAppClient) wrapNewsletterInfo(ctx context.Context, info *types.Ne
Type: ptr.Ptr(database.RoomTypeDefault),
}
}
+
+func (wa *WhatsAppClient) isUserBlocked(ctx context.Context, jid types.JID) (*bool, error) {
+ wa.blocklistLock.Lock()
+ defer wa.blocklistLock.Unlock()
+ meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
+ blocklist := meta.Blocklist
+ if blocklist == nil {
+ runHandlers := wa.resyncBlocklistUnlocked(ctx)
+ go runHandlers()
+ blocklist = meta.Blocklist
+ }
+ if blocklist == nil {
+ return nil, nil
+ }
+ return new(blocklist.IsBlocked(jid)), nil
+}
diff --git a/pkg/connector/client.go b/pkg/connector/client.go
index 9a54fcd9..61fceecf 100644
--- a/pkg/connector/client.go
+++ b/pkg/connector/client.go
@@ -122,6 +122,7 @@ type WhatsAppClient struct {
isNewLogin bool
pushNamesSynced *exsync.Event
lastPresence types.Presence
+ blocklistLock sync.Mutex
disableNewsletter bool
diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go
index c55f523c..a646ac9f 100644
--- a/pkg/connector/handlematrix.go
+++ b/pkg/connector/handlematrix.go
@@ -1,3 +1,19 @@
+// 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 (
@@ -19,6 +35,7 @@ import (
"go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
"golang.org/x/image/draw"
"google.golang.org/protobuf/proto"
"maunium.net/go/mautrix/bridgev2"
@@ -46,6 +63,7 @@ var (
_ bridgev2.TagHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.MarkedUnreadHandlingNetworkAPI = (*WhatsAppClient)(nil)
_ bridgev2.DeleteChatHandlingNetworkAPI = (*WhatsAppClient)(nil)
+ _ bridgev2.UserBlockingNetworkAPI = (*WhatsAppClient)(nil)
)
func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (result *bridgev2.MatrixMessageResponse, retErr error) {
@@ -691,3 +709,33 @@ func (wa *WhatsAppClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridg
defer wa.mcTrack(msg, time.Now(), &retErr)
return wa.Client.SendAppState(ctx, appstate.BuildDeleteChat(chatJID, lastTS, lastKey, true))
}
+
+func (wa *WhatsAppClient) HandleMatrixBlockUser(ctx context.Context, msg *bridgev2.MatrixBlockUser) error {
+ if wa.Client == nil {
+ return bridgev2.ErrNotLoggedIn
+ }
+ jid, err := waid.ParsePortalID(msg.Portal.ID)
+ if err != nil {
+ return err
+ }
+ if msg.Content.ReportSpam {
+ return fmt.Errorf("spam reporting is not supported")
+ }
+ action := events.BlocklistChangeActionUnblock
+ if msg.Content.Block {
+ action = events.BlocklistChangeActionBlock
+ }
+ blocklist, err := wa.Client.UpdateBlocklist(ctx, jid, action, "")
+ if err == nil {
+ wa.blocklistLock.Lock()
+ evts := wa.saveBlocklistUnlocked(ctx, blocklist)
+ wa.blocklistLock.Unlock()
+ for _, evt := range evts {
+ if evt.PortalKey == msg.Portal.PortalKey {
+ continue
+ }
+ wa.UserLogin.QueueRemoteEvent(evt)
+ }
+ }
+ return err
+}
diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go
index d320ad1f..117cfad9 100644
--- a/pkg/connector/handlewhatsapp.go
+++ b/pkg/connector/handlewhatsapp.go
@@ -82,6 +82,8 @@ func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) {
success = true
switch evt := rawEvt.(type) {
+ case *events.Blocklist:
+ success = wa.resyncBlocklist(ctx)()
case *events.Message:
success = wa.handleWAMessage(ctx, evt)
case *events.Receipt:
@@ -1016,3 +1018,93 @@ func (wa *WhatsAppClient) handleWAAppStateSyncError(ctx context.Context, evt *ev
}
}()
}
+
+func (wa *WhatsAppClient) resyncBlocklist(ctx context.Context) func() bool {
+ wa.blocklistLock.Lock()
+ defer wa.blocklistLock.Unlock()
+ return wa.resyncBlocklistUnlocked(ctx)
+}
+
+func noopUpdates() bool {
+ return true
+}
+
+func (wa *WhatsAppClient) resyncBlocklistUnlocked(ctx context.Context) func() bool {
+ meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
+ dhash := ptr.Val(meta.Blocklist).DHash
+ newBlocklist, err := wa.Client.GetBlocklist(ctx, dhash)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to get new blocklist")
+ return noopUpdates
+ } else if newBlocklist == nil {
+ zerolog.Ctx(ctx).Debug().Msg("Blocklist didn't change")
+ return noopUpdates
+ }
+ updateEvents := wa.saveBlocklistUnlocked(ctx, newBlocklist)
+ return func() bool {
+ for _, evt := range updateEvents {
+ res := wa.UserLogin.QueueRemoteEvent(evt)
+ if !res.Success {
+ return false
+ }
+ }
+ return true
+ }
+}
+
+func (wa *WhatsAppClient) saveBlocklistUnlocked(ctx context.Context, newBlocklist *types.Blocklist) []*simplevent.ChatInfoChange {
+ if newBlocklist.AddressingMode != types.AddressingModeLID {
+ zerolog.Ctx(ctx).Warn().Msg("Non-LID addressing mode in blocklist")
+ }
+ meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
+ oldBlocklist := ptr.Val(meta.Blocklist)
+ removed, added := exslices.Diff(oldBlocklist.LIDs(), newBlocklist.LIDs())
+ var updateEvents []*simplevent.ChatInfoChange
+ for _, jid := range removed {
+ if evt := wa.makeBlockUpdateEvent(ctx, jid, false); evt != nil {
+ updateEvents = append(updateEvents, evt)
+ }
+ }
+ for _, jid := range added {
+ if evt := wa.makeBlockUpdateEvent(ctx, jid, true); evt != nil {
+ updateEvents = append(updateEvents, evt)
+ }
+ }
+ meta.Blocklist = newBlocklist
+ err := wa.UserLogin.Save(ctx)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to save blocklist to login metadata")
+ }
+ return updateEvents
+}
+
+func (wa *WhatsAppClient) makeBlockUpdateEvent(ctx context.Context, jid types.JID, blocked bool) *simplevent.ChatInfoChange {
+ if jid.Server == types.DefaultUserServer {
+ var err error
+ jid, err = wa.GetStore().LIDs.GetLIDForPN(ctx, jid)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Stringer("pn", jid).Msg("Failed to get LID for blocklist update")
+ return nil
+ } else if jid.IsEmpty() {
+ zerolog.Ctx(ctx).Debug().Stringer("pn", jid).Msg("No LID for blocklist update")
+ return nil
+ }
+ } else if jid.Server != types.HiddenUserServer && jid.Server != types.BotServer {
+ zerolog.Ctx(ctx).Debug().Stringer("jid", jid).Msg("Unexpected JID server in blocklist")
+ return nil
+ }
+ return &simplevent.ChatInfoChange{
+ EventMeta: simplevent.EventMeta{
+ Type: bridgev2.RemoteEventChatInfoChange,
+ PortalKey: wa.makeWAPortalKey(jid),
+ LogContext: func(c zerolog.Context) zerolog.Context {
+ return c.Str("wa_event_type", "blocklist update")
+ },
+ },
+ ChatInfoChange: &bridgev2.ChatInfoChange{
+ ChatInfo: &bridgev2.ChatInfo{
+ UserBlocked: &blocked,
+ },
+ },
+ }
+}
diff --git a/pkg/waid/dbmeta.go b/pkg/waid/dbmeta.go
index 6e018f63..15c0a422 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"`
+ Blocklist *types.Blocklist `json:"blocklist,omitempty"`
+
MData json.RawMessage `json:"mdata,omitempty"`
}
From cb7d14e9794300944722ac22c58c200692321e42 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Tue, 6 Oct 2026 16:01:26 +0300
Subject: [PATCH 141/203] handlewhatsapp: fetch blocked DMs from db for
dispatching updates
---
pkg/connector/handlewhatsapp.go | 21 +++++++++++++++++++--
1 file changed, 19 insertions(+), 2 deletions(-)
diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go
index 117cfad9..2b30fdde 100644
--- a/pkg/connector/handlewhatsapp.go
+++ b/pkg/connector/handlewhatsapp.go
@@ -1058,7 +1058,24 @@ func (wa *WhatsAppClient) saveBlocklistUnlocked(ctx context.Context, newBlocklis
}
meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata)
oldBlocklist := ptr.Val(meta.Blocklist)
- removed, added := exslices.Diff(oldBlocklist.LIDs(), newBlocklist.LIDs())
+ currentlyBlockedDMs, err := wa.Main.Bridge.DB.Portal.GetAllBlockedDMsOf(ctx, wa.UserLogin.ID)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to get currently blocked DMs")
+ }
+ // In addition to diffing the cached blocklist, fetch all blocked DM portals from the database
+ // to ensure any missed updates are retried.
+ oldBlockedLIDs := exslices.CastFuncFilter[types.JID](currentlyBlockedDMs, func(key networkid.PortalKey) (types.JID, bool) {
+ jid, _ := waid.ParsePortalID(key.ID)
+ return jid, !jid.IsEmpty()
+ })
+ removed, added := exslices.Diff(append(oldBlocklist.LIDs(), oldBlockedLIDs...), newBlocklist.LIDs())
+ zerolog.Ctx(ctx).Debug().
+ Int("old_count", len(oldBlocklist.Items)).
+ Int("db_count", len(currentlyBlockedDMs)).
+ Int("new_count", len(newBlocklist.Items)).
+ Any("removed", removed).
+ Any("added", added).
+ Msg("Syncing blocklist")
var updateEvents []*simplevent.ChatInfoChange
for _, jid := range removed {
if evt := wa.makeBlockUpdateEvent(ctx, jid, false); evt != nil {
@@ -1071,7 +1088,7 @@ func (wa *WhatsAppClient) saveBlocklistUnlocked(ctx context.Context, newBlocklis
}
}
meta.Blocklist = newBlocklist
- err := wa.UserLogin.Save(ctx)
+ err = wa.UserLogin.Save(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save blocklist to login metadata")
}
From 9959878fe0c3071bb6729d54befb0b843c4b71d3 Mon Sep 17 00:00:00 2001
From: Tulir Asokan
Date: Tue, 6 Oct 2026 16:05:15 +0300
Subject: [PATCH 142/203] handlematrix: dispatch extra block updates in the
background
---
pkg/connector/handlematrix.go | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go
index a646ac9f..ee3d70a6 100644
--- a/pkg/connector/handlematrix.go
+++ b/pkg/connector/handlematrix.go
@@ -728,14 +728,16 @@ func (wa *WhatsAppClient) HandleMatrixBlockUser(ctx context.Context, msg *bridge
blocklist, err := wa.Client.UpdateBlocklist(ctx, jid, action, "")
if err == nil {
wa.blocklistLock.Lock()
+ defer wa.blocklistLock.Unlock()
evts := wa.saveBlocklistUnlocked(ctx, blocklist)
- wa.blocklistLock.Unlock()
- for _, evt := range evts {
- if evt.PortalKey == msg.Portal.PortalKey {
- continue
+ go func() {
+ for _, evt := range evts {
+ if evt.PortalKey == msg.Portal.PortalKey {
+ continue
+ }
+ wa.UserLogin.QueueRemoteEvent(evt)
}
- wa.UserLogin.QueueRemoteEvent(evt)
- }
+ }()
}
return err
}
From 5f904a6203db8f1fc45d799514faf5298bb34656 Mon Sep 17 00:00:00 2001
From: Gerardo Rodriguez
Date: Tue, 6 Oct 2026 13:52:22 +0000
Subject: [PATCH 143/203] msgconv/from-matrix: try to fill outgoing reply
quotes (#963)
---
pkg/msgconv/from-matrix.go | 36 ++++++++++++++++++++++++++++++++++--
pkg/msgconv/matrixpoll.go | 2 +-
2 files changed, 35 insertions(+), 3 deletions(-)
diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go
index b3ddcf90..6f1d5153 100644
--- a/pkg/msgconv/from-matrix.go
+++ b/pkg/msgconv/from-matrix.go
@@ -53,6 +53,7 @@ import (
func (mc *MessageConverter) generateContextInfo(
ctx context.Context,
replyTo *database.Message,
+ relatesTo *event.RelatesTo,
portal *bridgev2.Portal,
perMessageTimer *event.BeeperDisappearingTimer,
roomMention bool,
@@ -63,7 +64,7 @@ func (mc *MessageConverter) generateContextInfo(
if err == nil {
contextInfo.StanzaID = proto.String(msgID.ID)
contextInfo.Participant = proto.String(msgID.Sender.String())
- contextInfo.QuotedMessage = &waE2E.Message{Conversation: proto.String("")}
+ contextInfo.QuotedMessage = mc.getQuotedMessage(ctx, replyTo, relatesTo, portal)
contextInfo.QuotedType = waE2E.ContextInfo_EXPLICIT.Enum()
} else {
zerolog.Ctx(ctx).Warn().Err(err).
@@ -91,6 +92,37 @@ func (mc *MessageConverter) generateContextInfo(
return contextInfo
}
+func (mc *MessageConverter) getQuotedMessage(ctx context.Context, replyTo *database.Message, relatesTo *event.RelatesTo, portal *bridgev2.Portal) *waE2E.Message {
+ log := zerolog.Ctx(ctx).With().Stringer("reply_to_event_id", replyTo.MXID).Logger()
+ if relatesTo.GetReplyTo() == replyTo.MXID && len(relatesTo.InReplyTo.BeeperQuote) > 0 {
+ var content event.MessageEventContent
+ if err := json.Unmarshal(relatesTo.InReplyTo.BeeperQuote, &content); err != nil {
+ log.Warn().Err(err).Msg("Failed to parse quoted event content")
+ } else {
+ return mc.convertQuote(ctx, content)
+ }
+ }
+ evt, err := mc.Bridge.Bot.GetEvent(ctx, portal.MXID, replyTo.MXID)
+ if err != nil {
+ log.Debug().Err(err).Msg("Failed to fetch quoted event")
+ } else if evt == nil {
+ log.Debug().Msg("Quoted event not found")
+ } else if evt.RoomID != "" && evt.RoomID != portal.MXID {
+ log.Warn().Msg("Quoted event is in a different room")
+ } else if evt.Unsigned.RedactedBecause == nil {
+ return mc.convertQuote(ctx, *evt.Content.AsMessage())
+ }
+ return &waE2E.Message{Conversation: new("")}
+}
+
+func (mc *MessageConverter) convertQuote(ctx context.Context, content event.MessageEventContent) *waE2E.Message {
+ content.Mentions = &event.Mentions{}
+ content.RemoveReplyFallback()
+ content.RemovePerMessageProfileFallback()
+ text, _ := mc.parseText(ctx, &content)
+ return &waE2E.Message{Conversation: &text}
+}
+
func (mc *MessageConverter) ToWhatsApp(
ctx context.Context,
client *whatsmeow.Client,
@@ -107,7 +139,7 @@ func (mc *MessageConverter) ToWhatsApp(
}
message := &waE2E.Message{}
- contextInfo := mc.generateContextInfo(ctx, replyTo, portal, content.BeeperDisappearingTimer, content.Mentions != nil && content.Mentions.Room)
+ contextInfo := mc.generateContextInfo(ctx, replyTo, content.RelatesTo, portal, content.BeeperDisappearingTimer, content.Mentions != nil && content.Mentions.Room)
switch content.MsgType {
case event.MsgText, event.MsgNotice, event.MsgEmote:
diff --git a/pkg/msgconv/matrixpoll.go b/pkg/msgconv/matrixpoll.go
index 5d5d0145..fab83303 100644
--- a/pkg/msgconv/matrixpoll.go
+++ b/pkg/msgconv/matrixpoll.go
@@ -71,7 +71,7 @@ func (mc *MessageConverter) PollStartToWhatsApp(
if maxAnswers >= len(content.PollStart.Answers) || maxAnswers < 0 {
maxAnswers = 0
}
- contextInfo := mc.generateContextInfo(ctx, replyTo, portal, nil, content.Mentions != nil && content.Mentions.Room)
+ contextInfo := mc.generateContextInfo(ctx, replyTo, content.RelatesTo, portal, nil, content.Mentions != nil && content.Mentions.Room)
var question string
question, contextInfo.MentionedJID = mc.msc1767ToWhatsApp(ctx, content.PollStart.Question, content.Mentions)
if len(question) == 0 {
From 977389392c7839330a4a1cfe49a74045a38d6f27 Mon Sep 17 00:00:00 2001
From: Matt Voska <3444419+voska@users.noreply.github.com>
Date: Tue, 6 Oct 2026 15:10:40 -0300
Subject: [PATCH 144/203] msgconv/from-whatsapp: add support for contact array
messages (#961)
---
pkg/msgconv/wa-contact.go | 22 +++++++++++++++-------
1 file changed, 15 insertions(+), 7 deletions(-)
diff --git a/pkg/msgconv/wa-contact.go b/pkg/msgconv/wa-contact.go
index 7ca3f8e5..38076939 100644
--- a/pkg/msgconv/wa-contact.go
+++ b/pkg/msgconv/wa-contact.go
@@ -19,8 +19,10 @@ package msgconv
import (
"context"
"fmt"
+ "strings"
"github.com/rs/zerolog"
+ "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow/proto/waE2E"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
@@ -65,11 +67,17 @@ func (mc *MessageConverter) convertContactMessage(ctx context.Context, msg *waE2
}
func (mc *MessageConverter) convertContactsArrayMessage(ctx context.Context, msg *waE2E.ContactsArrayMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) {
- return &bridgev2.ConvertedMessagePart{
- Type: event.EventMessage,
- Content: &event.MessageEventContent{
- MsgType: event.MsgNotice,
- Body: "Contact array messages are not yet supported",
- },
- }, msg.GetContextInfo()
+ name := msg.GetDisplayName()
+ if len(name) == 0 {
+ name = fmt.Sprintf("%d contacts", len(msg.GetContacts()))
+ }
+ vcards := make([]string, len(msg.GetContacts()))
+ for i, contact := range msg.GetContacts() {
+ vcards[i] = strings.TrimSpace(contact.GetVcard())
+ }
+ part, _ := mc.convertContactMessage(ctx, &waE2E.ContactMessage{
+ DisplayName: &name,
+ Vcard: ptr.Ptr(strings.Join(vcards, "\n")),
+ })
+ return part, msg.GetContextInfo()
}
From aa86329e3717541b5db8ddd9e434019c8ce71070 Mon Sep 17 00:00:00 2001
From: Gerardo Rodriguez
Date: Wed, 7 Oct 2026 14:00:27 +0000
Subject: [PATCH 145/203] msgconv/from-matrix: use any available H.264 encoder
for video conversion (#968)
---
pkg/msgconv/from-matrix.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pkg/msgconv/from-matrix.go b/pkg/msgconv/from-matrix.go
index 6f1d5153..833813f7 100644
--- a/pkg/msgconv/from-matrix.go
+++ b/pkg/msgconv/from-matrix.go
@@ -617,7 +617,7 @@ func (mc *MessageConverter) reuploadFileToWhatsApp(
sourceFormat = "mov"
}
data, err = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", sourceFormat}, []string{
- "-pix_fmt", "yuv420p", "-c:v", "libx264",
+ "-pix_fmt", "yuv420p", "-c:v", "h264", "-b:v", "2M",
"-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'",
}, mime)
if err != nil {
@@ -626,7 +626,7 @@ func (mc *MessageConverter) reuploadFileToWhatsApp(
mime = "video/mp4"
case "image/gif":
data, err = ffmpeg.ConvertBytes(ctx, data, ".mp4", []string{"-f", "gif"}, []string{
- "-pix_fmt", "yuv420p", "-c:v", "libx264", "-movflags", "+faststart",
+ "-pix_fmt", "yuv420p", "-c:v", "h264", "-b:v", "2M", "-movflags", "+faststart",
"-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'",
}, mime)
if err != nil {
From be8d93edc441b436db510f646cba2184cd0a7a3e Mon Sep 17 00:00:00 2001
From: Nick Mills-Barrett
Date: Fri, 9 Oct 2026 15:23:35 +0100
Subject: [PATCH 146/203] msgconv/from-whatsapp: flag message history share
notices as action messages (#971)
---
pkg/msgconv/wa-misc.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/pkg/msgconv/wa-misc.go b/pkg/msgconv/wa-misc.go
index cda33c50..275b1326 100644
--- a/pkg/msgconv/wa-misc.go
+++ b/pkg/msgconv/wa-misc.go
@@ -199,8 +199,9 @@ func (mc *MessageConverter) convertMessageHistoryNotice(ctx context.Context, inf
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
- MsgType: event.MsgNotice,
- Body: body,
+ MsgType: event.MsgNotice,
+ Body: body,
+ BeeperActionMessage: &event.BeeperActionMessage{Type: "message_history_notice"},
},
}, msg.GetContextInfo()
}
From 45d51e3905ecd28dd6cb90e6ca0bd47512fc201b Mon Sep 17 00:00:00 2001
From: Gerardo Rodriguez
Date: Sat, 10 Oct 2026 10:47:11 +0000
Subject: [PATCH 147/203] login: fetch own avatar after new logins (#970)
---
pkg/connector/login.go | 2 ++
1 file changed, 2 insertions(+)
diff --git a/pkg/connector/login.go b/pkg/connector/login.go
index e5f7cd48..325bc550 100644
--- a/pkg/connector/login.go
+++ b/pkg/connector/login.go
@@ -13,6 +13,7 @@ import (
"github.com/rs/zerolog"
"go.mau.fi/util/exsync"
"go.mau.fi/util/jsontime"
+ "go.mau.fi/util/ptr"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
@@ -418,6 +419,7 @@ func (wl *WALogin) onLoginComplete(ctx context.Context) (*bridgev2.LoginStep, er
if err != nil {
wl.Log.Warn().Err(err).Msg("Prekey upload wait failed")
}
+ go c.syncGhost(c.JID.ToNonAD(), "login", ptr.Ptr(""))
}
return &bridgev2.LoginStep{
From 1fc894948c033c300529bd96a282a9e7159ef543 Mon Sep 17 00:00:00 2001
From: Gerardo Rodriguez
Date: Mon, 12 Oct 2026 12:34:18 +0000
Subject: [PATCH 148/203] 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 149/203] 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 150/203] 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 151/203] 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 152/203] 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 153/203] 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 154/203] 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 155/203] 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 156/203] 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 157/203] 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 158/203] 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 159/203] 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 160/203] 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 161/203] 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 162/203] 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 163/203] 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 164/203] 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 165/203] 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 166/203] 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 167/203] 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 168/203] 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 169/203] 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 170/203] 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 171/203] 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 172/203] 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 173/203] 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 174/203] 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 175/203] 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 176/203] 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 177/203] 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 178/203] 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 179/203] 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 180/203] 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 181/203] 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 182/203] 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 183/203] 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 184/203] 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 185/203] 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 186/203] 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 187/203] 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 188/203] 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 189/203] 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 190/203] 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 191/203] 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 192/203] 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 193/203] 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 194/203] 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 195/203] 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 196/203] 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 197/203] 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 198/203] 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 199/203] 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 200/203] 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 201/203] 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 202/203] 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 203/203] 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()).