diff --git a/go.mod b/go.mod
index 592b1efa..3d428fe0 100644
--- a/go.mod
+++ b/go.mod
@@ -18,7 +18,7 @@ require (
golang.org/x/sync v0.8.0
google.golang.org/protobuf v1.34.2
gopkg.in/yaml.v3 v3.0.1
- maunium.net/go/mautrix v0.21.1-0.20241010140510-38610d681dcd
+ maunium.net/go/mautrix v0.21.1-0.20241014164722-965008e8462e
)
require (
diff --git a/go.sum b/go.sum
index 59a467cb..432d903c 100644
--- a/go.sum
+++ b/go.sum
@@ -101,5 +101,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.21.1-0.20241010140510-38610d681dcd h1:otYRelHwBCFpyqc0zIk4RjUx0tVbUuKstsfE0mLWcfU=
-maunium.net/go/mautrix v0.21.1-0.20241010140510-38610d681dcd/go.mod h1:+fF5qsmXRCEXQZgW5ececC0PI3c7gISHTLcyftP4Bh0=
+maunium.net/go/mautrix v0.21.1-0.20241014164722-965008e8462e h1:iGplBWWCj/QJ9yceX8jg6LS6tZZKKmm5uX3MUQxR4Rg=
+maunium.net/go/mautrix v0.21.1-0.20241014164722-965008e8462e/go.mod h1:yIs8uVcl3ZiTuDzAYmk/B4/z9dQqegF0rcOWV4ncgko=
diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go
index ee1c615e..8fc09eaa 100644
--- a/pkg/connector/backfill.go
+++ b/pkg/connector/backfill.go
@@ -330,7 +330,7 @@ func (wa *WhatsAppClient) convertHistorySyncMessage(
// TODO use proper intent
intent := wa.Main.Bridge.Bot
wrapped := &bridgev2.BackfillMessage{
- ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, intent, msg, info, isViewOnce),
+ ConvertedMessage: wa.Main.MsgConv.ToMatrix(ctx, portal, wa.Client, intent, msg, info, isViewOnce, nil),
Sender: wa.makeEventSender(info.Sender),
ID: waid.MakeMessageID(info.Chat, info.Sender, info.ID),
TxnID: networkid.TransactionID(waid.MakeMessageID(info.Chat, info.Sender, info.ID)),
diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go
index dc3e18f5..0c196042 100644
--- a/pkg/connector/capabilities.go
+++ b/pkg/connector/capabilities.go
@@ -18,6 +18,7 @@ func (wa *WhatsAppConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilit
}
const WAMaxFileSize = 2000 * 1024 * 1024
+const EditMaxAge = 15 * time.Minute
var whatsappCaps = &bridgev2.NetworkRoomCapabilities{
FormattedText: true,
@@ -28,7 +29,7 @@ var whatsappCaps = &bridgev2.NetworkRoomCapabilities{
Polls: true,
Edits: true,
EditMaxCount: 10,
- EditMaxAge: 15 * time.Minute,
+ EditMaxAge: EditMaxAge,
Deletes: true,
DeleteMaxAge: 48 * time.Hour,
DefaultFileRestriction: &bridgev2.FileRestriction{
diff --git a/pkg/connector/client.go b/pkg/connector/client.go
index bf22c290..9f7f5021 100644
--- a/pkg/connector/client.go
+++ b/pkg/connector/client.go
@@ -153,6 +153,7 @@ func (wa *WhatsAppClient) Connect(ctx context.Context) error {
wa.UserLogin.BridgeState.Send(state)
return nil
}
+ wa.Main.firstClientConnectOnce.Do(wa.Main.onFirstClientConnect)
if err := wa.Main.updateProxy(ctx, wa.Client, false); err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to update proxy")
}
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index 85f7266b..0db5b899 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -1,8 +1,26 @@
+// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2024 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"
"strings"
+ "sync"
+ "sync/atomic"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
@@ -23,10 +41,19 @@ type WhatsAppConnector struct {
DeviceStore *sqlstore.Container
MsgConv *msgconv.MessageConverter
DB *wadb.Database
+
+ firstClientConnectOnce sync.Once
+
+ mediaEditCache MediaEditCache
+ mediaEditCacheLock sync.RWMutex
+ stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc]
}
-var _ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil)
-var _ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil)
+var (
+ _ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil)
+ _ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil)
+ _ bridgev2.StoppableNetwork = (*WhatsAppConnector)(nil)
+)
func (wa *WhatsAppConnector) SetMaxFileSize(maxSize int64) {
wa.MsgConv.MaxFileSize = maxSize
@@ -63,6 +90,7 @@ func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
wa.Bridge.Commands.(*commands.Processor).AddHandlers(
cmdAccept,
)
+ wa.mediaEditCache = make(MediaEditCache)
wa.DeviceStore = sqlstore.NewWithDB(
bridge.DB.RawDB,
@@ -95,6 +123,16 @@ func (wa *WhatsAppConnector) Start(ctx context.Context) error {
return bridgev2.DBUpgradeError{Err: err, Section: "whatsapp"}
}
+ return nil
+}
+
+func (wa *WhatsAppConnector) Stop() {
+ if stop := wa.stopMediaEditCacheLoop.Load(); stop != nil {
+ (*stop)()
+ }
+}
+
+func (wa *WhatsAppConnector) onFirstClientConnect() {
ver, err := whatsmeow.GetLatestVersion(nil)
if err != nil {
wa.Bridge.Log.Err(err).Msg("Failed to get latest WhatsApp web version number")
@@ -105,5 +143,7 @@ func (wa *WhatsAppConnector) Start(ctx context.Context) error {
Msg("Got latest WhatsApp web version number")
store.SetWAVersion(*ver)
}
- return nil
+ meclCtx, cancel := context.WithCancel(context.Background())
+ wa.stopMediaEditCacheLoop.Store(&cancel)
+ go wa.mediaEditCacheExpireLoop(meclCtx)
}
diff --git a/pkg/connector/events.go b/pkg/connector/events.go
index 2cf61ec8..bcf70deb 100644
--- a/pkg/connector/events.go
+++ b/pkg/connector/events.go
@@ -132,15 +132,16 @@ func (evt *WAMessageEvent) ConvertEdit(ctx context.Context, portal *bridgev2.Por
zerolog.Ctx(ctx).Warn().Msg("Got edit to message with multiple parts")
}
var editedMsg *waE2E.Message
+ var previouslyConvertedPart *bridgev2.ConvertedMessagePart
if evt.isUndecryptableUpsertSubEvent {
// TODO db metadata needs to be updated in this case to remove the error
editedMsg = evt.Message
} else {
editedMsg = evt.Message.GetProtocolMessage().GetEditedMessage()
+ previouslyConvertedPart = evt.wa.Main.GetMediaEditCache(portal, evt.GetTargetMessage())
}
- // TODO edits to media captions may not contain the media
- cm := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, editedMsg, &evt.Info, evt.isViewOnce())
+ cm := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, editedMsg, &evt.Info, evt.isViewOnce(), previouslyConvertedPart)
if evt.isUndecryptableUpsertSubEvent && isFailedMedia(cm) {
evt.postHandle = func() {
evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), cm, false)
@@ -228,11 +229,13 @@ func (evt *WAMessageEvent) HandleExisting(ctx context.Context, portal *bridgev2.
func (evt *WAMessageEvent) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
evt.wa.EnqueuePortalResync(portal)
- converted := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, evt.Message, &evt.Info, evt.isViewOnce())
+ converted := evt.wa.Main.MsgConv.ToMatrix(ctx, portal, evt.wa.Client, intent, evt.Message, &evt.Info, evt.isViewOnce(), nil)
if isFailedMedia(converted) {
evt.postHandle = func() {
evt.wa.processFailedMedia(ctx, portal.PortalKey, evt.GetID(), converted, false)
}
+ } else if len(converted.Parts) > 0 {
+ evt.wa.Main.AddMediaEditCache(portal, evt.GetID(), converted.Parts[0])
}
return converted, nil
}
diff --git a/pkg/connector/login.go b/pkg/connector/login.go
index 654121a3..361a3fd4 100644
--- a/pkg/connector/login.go
+++ b/pkg/connector/login.go
@@ -109,6 +109,7 @@ var (
const LoginConnectWait = 15 * time.Second
func (wl *WALogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
+ wl.Main.firstClientConnectOnce.Do(wl.Main.onFirstClientConnect)
device := wl.Main.DeviceStore.NewDevice()
wl.Client = whatsmeow.NewClient(device, waLog.Zerolog(wl.Log))
wl.Client.EnableAutoReconnect = false
diff --git a/pkg/connector/mediaeditcache.go b/pkg/connector/mediaeditcache.go
new file mode 100644
index 00000000..ee85ff38
--- /dev/null
+++ b/pkg/connector/mediaeditcache.go
@@ -0,0 +1,91 @@
+// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
+// Copyright (C) 2024 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"
+ "time"
+
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+)
+
+type MediaEditCacheKey struct {
+ MessageID networkid.MessageID
+ PortalMXID id.RoomID
+}
+
+type MediaEditCacheValue struct {
+ Part *bridgev2.ConvertedMessagePart
+ Expiry time.Time
+}
+
+type MediaEditCache map[MediaEditCacheKey]MediaEditCacheValue
+
+func (wa *WhatsAppConnector) mediaEditCacheExpireLoop(ctx context.Context) {
+ ticker := time.NewTicker(1 * time.Minute)
+ ctxDone := ctx.Done()
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ case <-ctxDone:
+ return
+ }
+ wa.expireMediaEditCache()
+ }
+}
+
+func (wa *WhatsAppConnector) AddMediaEditCache(portal *bridgev2.Portal, messageID networkid.MessageID, converted *bridgev2.ConvertedMessagePart) {
+ if converted.Type != event.EventSticker && !converted.Content.MsgType.IsMedia() {
+ return
+ }
+ wa.mediaEditCacheLock.Lock()
+ defer wa.mediaEditCacheLock.Unlock()
+ wa.mediaEditCache[MediaEditCacheKey{
+ MessageID: messageID,
+ PortalMXID: portal.MXID,
+ }] = MediaEditCacheValue{
+ Part: converted,
+ Expiry: time.Now().Add(EditMaxAge + 5*time.Minute),
+ }
+}
+
+func (wa *WhatsAppConnector) GetMediaEditCache(portal *bridgev2.Portal, messageID networkid.MessageID) *bridgev2.ConvertedMessagePart {
+ wa.mediaEditCacheLock.RLock()
+ defer wa.mediaEditCacheLock.RUnlock()
+ value, ok := wa.mediaEditCache[MediaEditCacheKey{
+ MessageID: messageID,
+ PortalMXID: portal.MXID,
+ }]
+ if !ok || time.Until(value.Expiry) < 0 {
+ return nil
+ }
+ return value.Part
+}
+
+func (wa *WhatsAppConnector) expireMediaEditCache() {
+ wa.mediaEditCacheLock.Lock()
+ defer wa.mediaEditCacheLock.Unlock()
+ for key, value := range wa.mediaEditCache {
+ if time.Until(value.Expiry) < 0 {
+ delete(wa.mediaEditCache, key)
+ }
+ }
+}
diff --git a/pkg/msgconv/from-whatsapp.go b/pkg/msgconv/from-whatsapp.go
index 126ab445..998a3544 100644
--- a/pkg/msgconv/from-whatsapp.go
+++ b/pkg/msgconv/from-whatsapp.go
@@ -103,6 +103,7 @@ func (mc *MessageConverter) ToMatrix(
waMsg *waE2E.Message,
info *types.MessageInfo,
isViewOnce bool,
+ previouslyConvertedPart *bridgev2.ConvertedMessagePart,
) *bridgev2.ConvertedMessage {
ctx = context.WithValue(ctx, contextKeyClient, client)
ctx = context.WithValue(ctx, contextKeyIntent, intent)
@@ -133,21 +134,21 @@ func (mc *MessageConverter) ToMatrix(
case waMsg.EventMessage != nil:
part, contextInfo = mc.convertEventMessage(ctx, waMsg.EventMessage)
case waMsg.ImageMessage != nil:
- part, contextInfo = mc.convertMediaMessage(ctx, waMsg.ImageMessage, "photo", isViewOnce)
+ part, contextInfo = mc.convertMediaMessage(ctx, waMsg.ImageMessage, "photo", isViewOnce, previouslyConvertedPart)
case waMsg.StickerMessage != nil:
- part, contextInfo = mc.convertMediaMessage(ctx, waMsg.StickerMessage, "sticker", isViewOnce)
+ part, contextInfo = mc.convertMediaMessage(ctx, waMsg.StickerMessage, "sticker", isViewOnce, previouslyConvertedPart)
case waMsg.VideoMessage != nil:
- part, contextInfo = mc.convertMediaMessage(ctx, waMsg.VideoMessage, "video attachment", isViewOnce)
+ part, contextInfo = mc.convertMediaMessage(ctx, waMsg.VideoMessage, "video attachment", isViewOnce, previouslyConvertedPart)
case waMsg.PtvMessage != nil:
- part, contextInfo = mc.convertMediaMessage(ctx, waMsg.PtvMessage, "video message", isViewOnce)
+ part, contextInfo = mc.convertMediaMessage(ctx, waMsg.PtvMessage, "video message", isViewOnce, previouslyConvertedPart)
case waMsg.AudioMessage != nil:
typeName := "audio attachment"
if waMsg.AudioMessage.GetPTT() {
typeName = "voice message"
}
- part, contextInfo = mc.convertMediaMessage(ctx, waMsg.AudioMessage, typeName, isViewOnce)
+ part, contextInfo = mc.convertMediaMessage(ctx, waMsg.AudioMessage, typeName, isViewOnce, previouslyConvertedPart)
case waMsg.DocumentMessage != nil:
- part, contextInfo = mc.convertMediaMessage(ctx, waMsg.DocumentMessage, "file attachment", isViewOnce)
+ part, contextInfo = mc.convertMediaMessage(ctx, waMsg.DocumentMessage, "file attachment", isViewOnce, previouslyConvertedPart)
case waMsg.LocationMessage != nil:
part, contextInfo = mc.convertLocationMessage(ctx, waMsg.LocationMessage)
case waMsg.LiveLocationMessage != nil:
diff --git a/pkg/msgconv/wa-business.go b/pkg/msgconv/wa-business.go
index 572b3d6c..4ac0f416 100644
--- a/pkg/msgconv/wa-business.go
+++ b/pkg/msgconv/wa-business.go
@@ -70,11 +70,11 @@ func (mc *MessageConverter) convertTemplateMessage(ctx context.Context, info *ty
var convertedTitle *bridgev2.ConvertedMessagePart
switch title := tpl.GetTitle().(type) {
case *waE2E.TemplateMessage_HydratedFourRowTemplate_DocumentMessage:
- convertedTitle, _ = mc.convertMediaMessage(ctx, title.DocumentMessage, "file attachment", false)
+ convertedTitle, _ = mc.convertMediaMessage(ctx, title.DocumentMessage, "file attachment", false, nil)
case *waE2E.TemplateMessage_HydratedFourRowTemplate_ImageMessage:
- convertedTitle, _ = mc.convertMediaMessage(ctx, title.ImageMessage, "photo", false)
+ convertedTitle, _ = mc.convertMediaMessage(ctx, title.ImageMessage, "photo", false, nil)
case *waE2E.TemplateMessage_HydratedFourRowTemplate_VideoMessage:
- convertedTitle, _ = mc.convertMediaMessage(ctx, title.VideoMessage, "video attachment", false)
+ convertedTitle, _ = mc.convertMediaMessage(ctx, title.VideoMessage, "video attachment", false, nil)
case *waE2E.TemplateMessage_HydratedFourRowTemplate_LocationMessage:
content = fmt.Sprintf("Unsupported location message\n\n%s", content)
case *waE2E.TemplateMessage_HydratedFourRowTemplate_HydratedTitleText:
diff --git a/pkg/msgconv/wa-media.go b/pkg/msgconv/wa-media.go
index 71d2d010..060d12dc 100644
--- a/pkg/msgconv/wa-media.go
+++ b/pkg/msgconv/wa-media.go
@@ -44,7 +44,13 @@ import (
"maunium.net/go/mautrix-whatsapp/pkg/waid"
)
-func (mc *MessageConverter) convertMediaMessage(ctx context.Context, msg MediaMessage, typeName string, isViewOnce bool) (part *bridgev2.ConvertedMessagePart, contextInfo *waE2E.ContextInfo) {
+func (mc *MessageConverter) convertMediaMessage(
+ ctx context.Context,
+ msg MediaMessage,
+ typeName string,
+ isViewOnce bool,
+ cachedPart *bridgev2.ConvertedMessagePart,
+) (part *bridgev2.ConvertedMessagePart, contextInfo *waE2E.ContextInfo) {
if mc.DisableViewOnce && isViewOnce {
return &bridgev2.ConvertedMessagePart{
Type: event.EventMessage,
@@ -60,6 +66,12 @@ func (mc *MessageConverter) convertMediaMessage(ctx context.Context, msg MediaMe
mc.parseFormatting(preparedMedia.MessageEventContent, false, false)
}
contextInfo = preparedMedia.ContextInfo
+ if cachedPart != nil && msg.GetDirectPath() == "" {
+ cachedPart.Content.Body = preparedMedia.Body
+ cachedPart.Content.Format = preparedMedia.Format
+ cachedPart.Content.FormattedBody = preparedMedia.FormattedBody
+ return cachedPart, contextInfo
+ }
err := mc.reuploadWhatsAppAttachment(ctx, msg, preparedMedia)
if err != nil {
part = mc.makeMediaFailure(ctx, preparedMedia, &FailedMediaKeys{