msgconv/from-whatsapp: cache converted media messages to bridge caption edits

This commit is contained in:
Tulir Asokan 2024-10-14 19:52:16 +03:00
parent c40fa063b4
commit d961ed1de7
12 changed files with 171 additions and 21 deletions

2
go.mod
View file

@ -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 (

4
go.sum
View file

@ -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=

View file

@ -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)),

View file

@ -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{

View file

@ -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")
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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)
}

View file

@ -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
}

View file

@ -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

View file

@ -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 <https://www.gnu.org/licenses/>.
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)
}
}
}

View file

@ -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:

View file

@ -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:

View file

@ -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{