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{