Add initial support for WhatsApp message edits

Sending will be disabled by default until official WhatsApp clients
start rendering edits. The implementation may also be incorrect.
This commit is contained in:
Tulir Asokan 2022-10-08 17:46:42 +03:00
parent 859355a3db
commit 1105530c9a
8 changed files with 96 additions and 15 deletions

View file

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

View file

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

View file

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

View file

@ -138,6 +138,7 @@ const (
MsgFake MessageType = "fake"
MsgNormal MessageType = "message"
MsgReaction MessageType = "reaction"
MsgEdit MessageType = "edit"
)
type Message struct {

View file

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

4
go.mod
View file

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

8
go.sum
View file

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

View file

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