diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9e40a..74d8a54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# v0.7.1 (unreleased) + +* Added support for wa.me/qr links in `!wa resolve-link`. +* Added option to sync group members in parallel to speed up syncing large + groups. +* Added initial support for WhatsApp message editing. + * Sending edits will be disabled by default until official WhatsApp clients + start rendering edits. +* Changed `private_chat_portal_meta` config option to be implicitly enabled in + encrypted rooms, matching the behavior of other mautrix bridges. +* Updated media bridging to check homeserver media size limit before + downloading media to avoid running out of memory. + * The bridge may still run out of ram when bridging files if your homeserver + has a large media size limit and a low bridge memory limit. + # v0.7.0 (2022-09-16) * Bumped minimum Go version to 1.18. diff --git a/config/bridge.go b/config/bridge.go index bb4fb86..e2b225d 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -110,6 +110,7 @@ type BridgeConfig struct { FederateRooms bool `yaml:"federate_rooms"` URLPreviews bool `yaml:"url_previews"` CaptionInMessage bool `yaml:"caption_in_message"` + SendWhatsAppEdits bool `yaml:"send_whatsapp_edits"` MessageHandlingTimeout struct { ErrorAfterStr string `yaml:"error_after"` diff --git a/config/upgrade.go b/config/upgrade.go index 2d83b58..d240258 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -93,6 +93,7 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Bool, "bridge", "crash_on_stream_replaced") helper.Copy(up.Bool, "bridge", "url_previews") helper.Copy(up.Bool, "bridge", "caption_in_message") + helper.Copy(up.Bool, "bridge", "send_whatsapp_edits") helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "error_after") helper.Copy(up.Str|up.Null, "bridge", "message_handling_timeout", "deadline") diff --git a/database/message.go b/database/message.go index 0244622..f80a21a 100644 --- a/database/message.go +++ b/database/message.go @@ -138,6 +138,7 @@ const ( MsgFake MessageType = "fake" MsgNormal MessageType = "message" MsgReaction MessageType = "reaction" + MsgEdit MessageType = "edit" ) type Message struct { diff --git a/example-config.yaml b/example-config.yaml index 89929d3..42888cf 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -287,6 +287,9 @@ bridge: # Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552. # This is currently not supported in most clients. caption_in_message: false + # Should Matrix edits be bridged to WhatsApp edits? + # Official WhatsApp clients don't render edits yet, but once they do, the bridge should work with them right away. + send_whatsapp_edits: false # Maximum time for handling Matrix events. Duration strings formatted for https://pkg.go.dev/time#ParseDuration # Null means there's no enforced timeout. message_handling_timeout: diff --git a/go.mod b/go.mod index b8c5f6a..5ae69c5 100644 --- a/go.mod +++ b/go.mod @@ -11,12 +11,12 @@ require ( github.com/prometheus/client_golang v1.13.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/tidwall/gjson v1.14.3 - go.mau.fi/whatsmeow v0.0.0-20220928114434-ebe489ef67ef + go.mau.fi/whatsmeow v0.0.0-20221008133908-7f01b3072802 golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 golang.org/x/net v0.0.0-20220812174116-3211cb980234 google.golang.org/protobuf v1.28.1 maunium.net/go/maulogger/v2 v2.3.2 - maunium.net/go/mautrix v0.12.2-0.20221003070712-77198cd4cd57 + maunium.net/go/mautrix v0.12.2-0.20221008135414-78f80c20b158 ) require ( diff --git a/go.sum b/go.sum index d29dc83..68115b3 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mau.fi/libsignal v0.0.0-20220628090436-4d18b66b087e h1:ByHDg+D+dMIGuBA2n+1xOUf4xr3FJFYg8yxl06s1YBE= go.mau.fi/libsignal v0.0.0-20220628090436-4d18b66b087e/go.mod h1:RCdzkTWSJv0AKGqurzPXJsEGIVMuQps3E/h7CMUPous= -go.mau.fi/whatsmeow v0.0.0-20220928114434-ebe489ef67ef h1:32Ki56jfx+tg8B8Qla/przLXJchD4Y2NtlggA1oG+cs= -go.mau.fi/whatsmeow v0.0.0-20220928114434-ebe489ef67ef/go.mod h1:hsjqq2xLuoFew8vbsDCJcGf5EbXCRcR/yoQ+87w6m3k= +go.mau.fi/whatsmeow v0.0.0-20221008133908-7f01b3072802 h1:dD9WVoIhSWoIu1qlM/LhsbJDBknq8K98LcKJQ2UbQeg= +go.mau.fi/whatsmeow v0.0.0-20221008133908-7f01b3072802/go.mod h1:hsjqq2xLuoFew8vbsDCJcGf5EbXCRcR/yoQ+87w6m3k= golang.org/x/crypto v0.0.0-20220817201139-bc19a97f63c8 h1:GIAS/yBem/gq2MUqgNIzUHW7cJMmx3TGZOrnyYaNQ6c= golang.org/x/crypto v0.0.0-20220817201139-bc19a97f63c8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU= @@ -100,5 +100,5 @@ 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/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0= maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= -maunium.net/go/mautrix v0.12.2-0.20221003070712-77198cd4cd57 h1:AXpCOSBuF61ETOTKz+295CIpZYhIlmOBHu7XeuETHRU= -maunium.net/go/mautrix v0.12.2-0.20221003070712-77198cd4cd57/go.mod h1:/jxQFIipObSsjZPH6o3xyUi8uoULz3Hfr/8p9loqpYE= +maunium.net/go/mautrix v0.12.2-0.20221008135414-78f80c20b158 h1:Q56l5MDNzcmL5E0+wsGRKyjFlgSTQ73JeTYQ2LdZ8FY= +maunium.net/go/mautrix v0.12.2-0.20221008135414-78f80c20b158/go.mod h1:/jxQFIipObSsjZPH6o3xyUi8uoULz3Hfr/8p9loqpYE= diff --git a/portal.go b/portal.go index 3a4e95a..6241f3d 100644 --- a/portal.go +++ b/portal.go @@ -422,6 +422,8 @@ func getMessageType(waMsg *waProto.Message) string { return "ignore" } return "revoke" + case waProto.ProtocolMessage_MESSAGE_EDIT: + return "edit" case waProto.ProtocolMessage_EPHEMERAL_SETTING: return "disappearing timer change" case waProto.ProtocolMessage_APP_STATE_SYNC_KEY_SHARE, waProto.ProtocolMessage_HISTORY_SYNC_NOTIFICATION, waProto.ProtocolMessage_INITIAL_SECURITY_NOTIFICATION_SETTING_SYNC: @@ -703,6 +705,22 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) { return } } + var editTargetMsg *database.Message + if msgType == "edit" { + editTargetID := evt.Message.GetProtocolMessage().GetKey().GetId() + editTargetMsg = portal.bridge.DB.Message.GetByJID(portal.Key, editTargetID) + if editTargetMsg == nil { + portal.log.Warnfln("Not handling %s: couldn't find edit target %s", msgID, editTargetID) + return + } else if editTargetMsg.Type != database.MsgNormal { + portal.log.Warnfln("Not handling %s: edit target %s is not a normal message (it's %s)", msgID, editTargetID, editTargetMsg.Type) + return + } else if editTargetMsg.Sender.User != evt.Info.Sender.User { + portal.log.Warnfln("Not handling %s: edit target %s was sent by %s, not %s", msgID, editTargetID, editTargetMsg.Sender.User, evt.Info.Sender.User) + return + } + evt.Message = evt.Message.GetProtocolMessage().GetEditedMessage() + } intent := portal.getMessageIntent(source, &evt.Info) if intent == nil { @@ -730,16 +748,23 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) { } else if converted.ReplyTo != nil { portal.SetReply(converted.Content, converted.ReplyTo, false) } + dbMsgType := database.MsgNormal + if editTargetMsg != nil { + dbMsgType = database.MsgEdit + converted.Content.SetEdit(editTargetMsg.MXID) + } resp, err := portal.sendMessage(converted.Intent, converted.Type, converted.Content, converted.Extra, evt.Info.Timestamp.UnixMilli()) if err != nil { portal.log.Errorfln("Failed to send %s to Matrix: %v", msgID, err) } else { - portal.MarkDisappearing(resp.EventID, converted.ExpiresIn, false) + if editTargetMsg == nil { + portal.MarkDisappearing(resp.EventID, converted.ExpiresIn, false) + } eventID = resp.EventID lastEventID = eventID } // TODO figure out how to handle captions with undecryptable messages turning decryptable - if converted.Caption != nil && existingMsg == nil { + if converted.Caption != nil && existingMsg == nil && editTargetMsg == nil { resp, err = portal.sendMessage(converted.Intent, converted.Type, converted.Caption, nil, evt.Info.Timestamp.UnixMilli()) if err != nil { portal.log.Errorfln("Failed to send caption of %s to Matrix: %v", msgID, err) @@ -748,7 +773,7 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) { lastEventID = resp.EventID } } - if converted.MultiEvent != nil && existingMsg == nil { + if converted.MultiEvent != nil && existingMsg == nil && editTargetMsg == nil { for index, subEvt := range converted.MultiEvent { resp, err = portal.sendMessage(converted.Intent, converted.Type, subEvt, nil, evt.Info.Timestamp.UnixMilli()) if err != nil { @@ -759,16 +784,17 @@ func (portal *Portal) handleMessage(source *User, evt *events.Message) { } } } - if source.MXID == intent.UserID { + if source.MXID == intent.UserID && portal.bridge.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { // There are some edge cases (like call notices) where previous messages aren't marked as read // when the user sends a message from another device, so just mark the new message as read to be safe. + // Hungryserv does this automatically, so the bridge doesn't need to do it manually. err = intent.SetReadMarkers(portal.MXID, source.makeReadMarkerContent(lastEventID, true)) if err != nil { portal.log.Warnfln("Failed to mark own message %s as read by %s: %v", lastEventID, source.MXID, err) } } if len(eventID) != 0 { - portal.finishHandling(existingMsg, &evt.Info, eventID, database.MsgNormal, converted.Error) + portal.finishHandling(existingMsg, &evt.Info, eventID, dbMsgType, converted.Error) } } else if msgType == "reaction" { portal.HandleMessageReaction(intent, source, &evt.Info, evt.Message.GetReactionMessage(), existingMsg) @@ -3138,8 +3164,18 @@ func (portal *Portal) convertMatrixMessage(ctx context.Context, sender *User, ev if !ok { return nil, sender, fmt.Errorf("%w %T", errUnexpectedParsedContentType, evt.Content.Parsed) } + var editRootMsg *database.Message + if editEventID := content.RelatesTo.GetReplaceID(); editEventID != "" && portal.bridge.Config.Bridge.SendWhatsAppEdits { + editRootMsg = portal.bridge.DB.Message.GetByMXID(editEventID) + if editRootMsg == nil || editRootMsg.Type != database.MsgNormal || editRootMsg.IsFakeJID() || editRootMsg.Sender.User != sender.JID.User { + return nil, sender, fmt.Errorf("edit rejected") // TODO more specific error message + } + if content.NewContent != nil { + content = content.NewContent + } + } - var msg waProto.Message + msg := &waProto.Message{} var ctxInfo waProto.ContextInfo replyToID := content.GetReplyTo() if len(replyToID) > 0 { @@ -3320,7 +3356,26 @@ func (portal *Portal) convertMatrixMessage(ctx context.Context, sender *User, ev default: return nil, sender, fmt.Errorf("%w %q", errUnknownMsgType, content.MsgType) } - return &msg, sender, nil + + if editRootMsg != nil { + msg = &waProto.Message{ + EditedMessage: &waProto.FutureProofMessage{ + Message: &waProto.Message{ + ProtocolMessage: &waProto.ProtocolMessage{ + Key: &waProto.MessageKey{ + FromMe: proto.Bool(true), + Id: proto.String(editRootMsg.JID), + RemoteJid: proto.String(portal.Key.JID.String()), + }, + Type: waProto.ProtocolMessage_MESSAGE_EDIT.Enum(), + EditedMessage: msg, + }, + }, + }, + } + } + + return msg, sender, nil } func (portal *Portal) generateMessageInfo(sender *User) *types.MessageInfo { @@ -3405,10 +3460,15 @@ func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event, timing go ms.sendMessageMetrics(evt, err, "Error converting", true) return } - portal.MarkDisappearing(origEvtID, portal.ExpirationTime, true) + dbMsgType := database.MsgNormal + if msg.EditedMessage == nil { + portal.MarkDisappearing(origEvtID, portal.ExpirationTime, true) + } else { + dbMsgType = database.MsgEdit + } info := portal.generateMessageInfo(sender) if dbMsg == nil { - dbMsg = portal.markHandled(nil, nil, info, evt.ID, false, true, database.MsgNormal, database.MsgNoError) + dbMsg = portal.markHandled(nil, nil, info, evt.ID, false, true, dbMsgType, database.MsgNoError) } else { info.ID = dbMsg.JID }