From 10a7c781e67d5e1a36dab9c4b2175ba51a98f6ba Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 15 Feb 2022 16:28:20 +0200 Subject: [PATCH] Add support for asking homeserver for URL previews --- CHANGELOG.md | 2 ++ config/bridge.go | 1 + config/upgrade.go | 1 + example-config.yaml | 4 +++ go.mod | 7 ++-- go.sum | 8 +++-- portal.go | 16 ++++----- urlpreview.go | 83 ++++++++++++++++++++++++++------------------- 8 files changed, 74 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f30c8fe..d0a8e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,13 @@ * (Re-)Added support for setting group avatar from Matrix. * Added initial support for re-fetching old media from phone. * Added support for bridging audio message waveforms in both directions. +* Added support for sending URL previews to WhatsApp (both custom and autogenerated). * Fixed some issues with read receipt bridging * Fixed `!wa open` not working with new-style group IDs. * Fixed panic in disappearing message handling code if a portal is deleted with messages still inside. * Fixed disappearing message timer not being stored in post-login history sync. +* Fixed formatting not being parsed in most incoming WhatsApp messages. # v0.2.3 (2022-01-16) diff --git a/config/bridge.go b/config/bridge.go index aad1ac4..6c64319 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -72,6 +72,7 @@ type BridgeConfig struct { WhatsappThumbnail bool `yaml:"whatsapp_thumbnail"` AllowUserInvite bool `yaml:"allow_user_invite"` FederateRooms bool `yaml:"federate_rooms"` + URLPreviews bool `yaml:"url_previews"` DisappearingMessagesInGroups bool `yaml:"disappearing_messages_in_groups"` diff --git a/config/upgrade.go b/config/upgrade.go index d7719c3..a34099d 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -104,6 +104,7 @@ func (helper *UpgradeHelper) doUpgrade() { helper.Copy(Bool, "bridge", "federate_rooms") helper.Copy(Bool, "bridge", "disappearing_messages_in_groups") helper.Copy(Bool, "bridge", "disable_bridge_alerts") + helper.Copy(Bool, "bridge", "url_previews") helper.Copy(Str, "bridge", "management_room_text", "welcome") helper.Copy(Str, "bridge", "management_room_text", "welcome_connected") helper.Copy(Str, "bridge", "management_room_text", "welcome_unconnected") diff --git a/example-config.yaml b/example-config.yaml index 9aceb13..836fc8a 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -194,6 +194,10 @@ bridge: # Should the bridge never send alerts to the bridge management room? # These are mostly things like the user being logged out. disable_bridge_alerts: false + # Should the bridge detect URLs in outgoing messages, ask the homeserver to generate a preview, + # and send it to WhatsApp? URL previews can always be sent using the `com.beeper.linkpreviews` + # key in the event content even if this is disabled. + url_previews: false # The prefix for commands. Only required in non-management rooms. command_prefix: "!wa" diff --git a/go.mod b/go.mod index ec3e77a..89f591c 100644 --- a/go.mod +++ b/go.mod @@ -12,11 +12,12 @@ require ( github.com/tidwall/gjson v1.14.0 go.mau.fi/whatsmeow v0.0.0-20220211173754-90c655671ab0 golang.org/x/image v0.0.0-20211028202545-6944b10bf410 + golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd google.golang.org/protobuf v1.27.1 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b maunium.net/go/mauflag v1.0.0 maunium.net/go/maulogger/v2 v2.3.2 - maunium.net/go/mautrix v0.10.11-0.20220215121349-628a694b037f + maunium.net/go/mautrix v0.10.11-0.20220215142712-441b0812745a ) require ( @@ -33,8 +34,8 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.4 // indirect go.mau.fi/libsignal v0.0.0-20211109153248-a67163214910 // indirect - golang.org/x/crypto v0.0.0-20220213190939-1e6e3497d506 // indirect - golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect + golang.org/x/crypto v0.0.0-20220214200702-86341886e292 // indirect golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect + golang.org/x/text v0.3.7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 1c1640b..0b4550d 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,9 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220213190939-1e6e3497d506 h1:EuGTJDfeg/PGZJp3gq1K+14eSLFTsrj1eg8KQuiUyKg= golang.org/x/crypto v0.0.0-20220213190939-1e6e3497d506/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292 h1:f+lwQ+GtmgoY+A2YaQxlSOnDjXcQ7ZRLWOHbC6HtRqE= +golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -167,6 +168,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= @@ -199,5 +201,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.10.11-0.20220215121349-628a694b037f h1:07t3qJkxqu7iQZ7OUIba7NkwyfpD6ul8GOjsM+Nemh0= -maunium.net/go/mautrix v0.10.11-0.20220215121349-628a694b037f/go.mod h1:Ynac6y32yvdJC8YiYvWjWp6u1WjVTNq+JssC+07ZZWw= +maunium.net/go/mautrix v0.10.11-0.20220215142712-441b0812745a h1:qemTkoULb98wqW1CrV0qD1SQZ4rQw6HgmIuzYyJ3N64= +maunium.net/go/mautrix v0.10.11-0.20220215142712-441b0812745a/go.mod h1:Ynac6y32yvdJC8YiYvWjWp6u1WjVTNq+JssC+07ZZWw= diff --git a/portal.go b/portal.go index e92d54a..e6c4694 100644 --- a/portal.go +++ b/portal.go @@ -2492,14 +2492,14 @@ func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waP if content.MsgType == event.MsgEmote && !relaybotFormatted { text = "/me " + text } - if ctxInfo.StanzaId != nil || ctxInfo.MentionedJid != nil || ctxInfo.Expiration != nil || evt.Content.Raw["com.beeper.linkpreviews"] != nil { - msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{ - Text: &text, - ContextInfo: &ctxInfo, - } - - portal.convertURLPreviewToWhatsApp(sender, evt, msg.ExtendedTextMessage) - } else { + msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{ + Text: &text, + ContextInfo: &ctxInfo, + } + hasPreview := portal.convertURLPreviewToWhatsApp(sender, evt, msg.ExtendedTextMessage) + if ctxInfo.StanzaId == nil && ctxInfo.MentionedJid == nil && ctxInfo.Expiration == nil && !hasPreview { + // No need for extended message + msg.ExtendedTextMessage = nil msg.Conversation = &text } case event.MsgImage: diff --git a/urlpreview.go b/urlpreview.go index 7dff94a..65cb184 100644 --- a/urlpreview.go +++ b/urlpreview.go @@ -22,34 +22,27 @@ import ( "encoding/json" "image" "net/http" + "net/url" + "regexp" "strings" "time" "github.com/tidwall/gjson" + "golang.org/x/net/idna" "google.golang.org/protobuf/proto" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" "go.mau.fi/whatsmeow" waProto "go.mau.fi/whatsmeow/binary/proto" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/event" ) type BeeperLinkPreview struct { - MatchedURL string `json:"matched_url"` - CanonicalURL string `json:"og:url,omitempty"` - Title string `json:"og:title,omitempty"` - Type string `json:"og:type,omitempty"` - Description string `json:"og:description,omitempty"` - - ImageURL id.ContentURIString `json:"og:image,omitempty"` + mautrix.RespPreviewURL + MatchedURL string `json:"matched_url"` ImageEncryption *event.EncryptedFileInfo `json:"beeper:image:encryption,omitempty"` - - ImageSize int `json:"matrix:image:size,omitempty"` - ImageWidth int `json:"og:image:width,omitempty"` - ImageHeight int `json:"og:image:height,omitempty"` - ImageType string `json:"og:image:type,omitempty"` } func (portal *Portal) convertURLPreviewToBeeper(intent *appservice.IntentAPI, source *User, msg *waProto.ExtendedTextMessage) []*BeeperLinkPreview { @@ -58,10 +51,12 @@ func (portal *Portal) convertURLPreviewToBeeper(intent *appservice.IntentAPI, so } output := &BeeperLinkPreview{ - MatchedURL: msg.GetMatchedText(), - CanonicalURL: msg.GetCanonicalUrl(), - Title: msg.GetTitle(), - Description: msg.GetDescription(), + MatchedURL: msg.GetMatchedText(), + RespPreviewURL: mautrix.RespPreviewURL{ + CanonicalURL: msg.GetCanonicalUrl(), + Title: msg.GetTitle(), + Description: msg.GetDescription(), + }, } if len(output.CanonicalURL) == 0 { output.CanonicalURL = output.MatchedURL @@ -115,19 +110,38 @@ func (portal *Portal) convertURLPreviewToBeeper(intent *appservice.IntentAPI, so return []*BeeperLinkPreview{output} } -func (portal *Portal) convertURLPreviewToWhatsApp(sender *User, evt *event.Event, dest *waProto.ExtendedTextMessage) { +var URLRegex = regexp.MustCompile(`https?://[^\s/_*]+(?:/\S*)?`) + +func (portal *Portal) convertURLPreviewToWhatsApp(sender *User, evt *event.Event, dest *waProto.ExtendedTextMessage) bool { + var preview *BeeperLinkPreview + rawPreview := gjson.GetBytes(evt.Content.VeryRaw, `com\.beeper\.linkpreviews`) - if !rawPreview.Exists() || !rawPreview.IsArray() { - return + if rawPreview.Exists() && rawPreview.IsArray() { + var previews []BeeperLinkPreview + if err := json.Unmarshal([]byte(rawPreview.Raw), &previews); err != nil || len(previews) == 0 { + return false + } + // WhatsApp only supports a single preview. + preview = &previews[0] + } else if portal.bridge.Config.Bridge.URLPreviews { + if matchedURL := URLRegex.FindString(evt.Content.AsMessage().Body); len(matchedURL) == 0 { + return false + } else if parsed, err := url.Parse(matchedURL); err != nil { + return false + } else if parsed.Host, err = idna.ToASCII(parsed.Host); err != nil { + return false + } else if mxPreview, err := portal.MainIntent().GetURLPreview(parsed.String()); err != nil { + portal.log.Warnfln("Failed to fetch preview for %s: %v", matchedURL, err) + return false + } else { + preview = &BeeperLinkPreview{ + RespPreviewURL: *mxPreview, + MatchedURL: matchedURL, + } + } } - var previews []BeeperLinkPreview - if err := json.Unmarshal([]byte(rawPreview.Raw), &previews); err != nil || len(previews) == 0 { - return - } - // WhatsApp only supports a single preview. - preview := previews[0] - if len(preview.MatchedURL) == 0 { - return + if preview == nil || len(preview.MatchedURL) == 0 { + return false } dest.MatchedText = &preview.MatchedURL @@ -151,20 +165,20 @@ func (portal *Portal) convertURLPreviewToWhatsApp(sender *User, evt *event.Event data, err := portal.MainIntent().DownloadBytes(imageMXC) if err != nil { portal.log.Errorfln("Failed to download URL preview image %s in %s: %v", preview.ImageURL, evt.ID, err) - return + return true } if preview.ImageEncryption != nil { data, err = preview.ImageEncryption.Decrypt(data) if err != nil { portal.log.Errorfln("Failed to decrypt URL preview image in %s: %v", evt.ID, err) - return + return true } } dest.MediaKeyTimestamp = proto.Int64(time.Now().Unix()) uploadResp, err := sender.Client.Upload(context.Background(), data, whatsmeow.MediaLinkThumbnail) if err != nil { portal.log.Errorfln("Failed to upload URL preview thumbnail in %s: %v", evt.ID, err) - return + return true } dest.ThumbnailSha256 = uploadResp.FileSHA256 dest.ThumbnailEncSha256 = uploadResp.FileEncSHA256 @@ -183,4 +197,5 @@ func (portal *Portal) convertURLPreviewToWhatsApp(sender *User, evt *event.Event dest.ThumbnailHeight = proto.Uint32(uint32(height)) } } + return true }