Add Matrix->WhatsApp replies and other stuff

This commit is contained in:
Tulir Asokan 2018-08-26 00:26:24 +03:00
parent 6d08a5ff6c
commit 060516f9cf
5 changed files with 303 additions and 103 deletions

View file

@ -4,6 +4,7 @@
* [x] Plain text * [x] Plain text
* [x] Formatted messages * [x] Formatted messages
* [x] Media/files * [x] Media/files
* [x] Replies
* [ ] Message redactions * [ ] Message redactions
* [ ] Presence * [ ] Presence
* [ ] Typing notifications * [ ] Typing notifications
@ -24,6 +25,7 @@
* [x] Plain text * [x] Plain text
* [x] Formatted messages * [x] Formatted messages
* [x] Media/files * [x] Media/files
* [x] Replies
* [ ] Message deletions * [ ] Message deletions
* [x] Avatars * [x] Avatars
* [x] Presence * [x] Presence

86
formatting.go Normal file
View file

@ -0,0 +1,86 @@
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2018 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 main
import (
"fmt"
"regexp"
"strings"
"maunium.net/go/gomatrix/format"
"maunium.net/go/mautrix-whatsapp/whatsapp-ext"
)
func (user *User) newHTMLParser() *format.HTMLParser {
return &format.HTMLParser{
TabsToSpaces: 4,
Newline: "\n",
PillConverter: func(mxid, eventID string) string {
if mxid[0] == '@' {
puppet := user.GetPuppetByMXID(mxid)
fmt.Println(mxid, puppet)
if puppet != nil {
return "@" + puppet.PhoneNumber()
}
}
return mxid
},
BoldConverter: func(text string) string {
return fmt.Sprintf("*%s*", text)
},
ItalicConverter: func(text string) string {
return fmt.Sprintf("_%s_", text)
},
StrikethroughConverter: func(text string) string {
return fmt.Sprintf("~%s~", text)
},
MonospaceConverter: func(text string) string {
return fmt.Sprintf("```%s```", text)
},
MonospaceBlockConverter: func(text string) string {
return fmt.Sprintf("```%s```", text)
},
}
}
var italicRegex = regexp.MustCompile("([\\s>~*]|^)_(.+?)_([^a-zA-Z\\d]|$)")
var boldRegex = regexp.MustCompile("([\\s>_~]|^)\\*(.+?)\\*([^a-zA-Z\\d]|$)")
var strikethroughRegex = regexp.MustCompile("([\\s>_*]|^)~(.+?)~([^a-zA-Z\\d]|$)")
var codeBlockRegex = regexp.MustCompile("```(?:.|\n)+?```")
var mentionRegex = regexp.MustCompile("@[0-9]+")
func (user *User) newWhatsAppFormatMaps() (map[*regexp.Regexp]string, map[*regexp.Regexp]func(string) string) {
return map[*regexp.Regexp]string{
italicRegex: "$1<em>$2</em>$3",
boldRegex: "$1<strong>$2</strong>$3",
strikethroughRegex: "$1<del>$2</del>$3",
}, map[*regexp.Regexp]func(string) string{
codeBlockRegex: func(str string) string {
str = str[3 : len(str)-3]
if strings.ContainsRune(str, '\n') {
return fmt.Sprintf("<pre><code>%s</code></pre>", str)
}
return fmt.Sprintf("<code>%s</code>", str)
},
mentionRegex: func(str string) string {
jid := str[1:] + whatsapp_ext.NewUserSuffix
puppet := user.GetPuppetByJID(jid)
return fmt.Sprintf(`<a href="https://matrix.to/#/%s">%s</a>`, puppet.MXID, puppet.Displayname)
},
}
}

292
portal.go
View file

@ -22,17 +22,18 @@ import (
"fmt" "fmt"
"html" "html"
"image" "image"
"io" "image/gif"
"image/jpeg"
"image/png"
"math/rand" "math/rand"
"mime" "mime"
"net/http" "net/http"
"regexp"
"strings" "strings"
"sync" "sync"
"github.com/Rhymen/go-whatsapp" "github.com/Rhymen/go-whatsapp"
waProto "github.com/Rhymen/go-whatsapp/binary/proto"
"maunium.net/go/gomatrix" "maunium.net/go/gomatrix"
"maunium.net/go/gomatrix/format"
log "maunium.net/go/maulogger" log "maunium.net/go/maulogger"
"maunium.net/go/mautrix-appservice" "maunium.net/go/mautrix-appservice"
"maunium.net/go/mautrix-whatsapp/database" "maunium.net/go/mautrix-whatsapp/database"
@ -278,13 +279,11 @@ func (portal *Portal) MarkHandled(jid types.WhatsAppMessageID, mxid types.Matrix
func (portal *Portal) GetMessageIntent(info whatsapp.MessageInfo) *appservice.IntentAPI { func (portal *Portal) GetMessageIntent(info whatsapp.MessageInfo) *appservice.IntentAPI {
if info.FromMe { if info.FromMe {
portal.log.Debugln("Unhandled message from me:", info.Id) return portal.user.GetPuppetByJID(portal.user.JID()).Intent()
return nil
} else if portal.IsPrivateChat() { } else if portal.IsPrivateChat() {
return portal.MainIntent() return portal.MainIntent()
} }
puppet := portal.user.GetPuppetByJID(info.SenderJid) return portal.user.GetPuppetByJID(info.SenderJid).Intent()
return puppet.Intent()
} }
func (portal *Portal) SetReply(content *gomatrix.Content, info whatsapp.MessageInfo) { func (portal *Portal) SetReply(content *gomatrix.Content, info whatsapp.MessageInfo) {
@ -303,29 +302,14 @@ func (portal *Portal) SetReply(content *gomatrix.Content, info whatsapp.MessageI
return return
} }
var codeBlockRegex = regexp.MustCompile("```((?:.|\n)+?)```")
var italicRegex = regexp.MustCompile("([\\s>~*]|^)_(.+?)_([^a-zA-Z\\d]|$)")
var boldRegex = regexp.MustCompile("([\\s>_~]|^)\\*(.+?)\\*([^a-zA-Z\\d]|$)")
var strikethroughRegex = regexp.MustCompile("([\\s>_*]|^)~(.+?)~([^a-zA-Z\\d]|$)")
var whatsAppFormat = map[*regexp.Regexp]string{
italicRegex: "$1<em>$2</em>$3",
boldRegex: "$1<strong>$2</strong>$3",
strikethroughRegex: "$1<del>$2</del>$3",
}
func (portal *Portal) ParseWhatsAppFormat(input string) string { func (portal *Portal) ParseWhatsAppFormat(input string) string {
output := html.EscapeString(input) output := html.EscapeString(input)
for regex, replacement := range whatsAppFormat { for regex, replacement := range portal.user.waReplString {
output = regex.ReplaceAllString(output, replacement) output = regex.ReplaceAllString(output, replacement)
} }
output = codeBlockRegex.ReplaceAllStringFunc(output, func(str string) string { for regex, replacer := range portal.user.waReplFunc {
str = str[3 : len(str)-3] output = regex.ReplaceAllStringFunc(output, replacer)
if strings.ContainsRune(str, '\n') { }
return fmt.Sprintf("<pre><code>%s</code></pre>", str)
}
return fmt.Sprintf("<code>%s</code>", str)
})
return output return output
} }
@ -451,37 +435,47 @@ func (portal *Portal) HandleMediaMessage(download func() ([]byte, error), thumbn
portal.log.Debugln("Handled message", info.Id, "->", resp.EventID) portal.log.Debugln("Handled message", info.Id, "->", resp.EventID)
} }
var htmlParser = format.HTMLParser{ func makeMessageID() *string {
TabsToSpaces: 4,
Newline: "\n",
PillConverter: func(mxid, eventID string) string {
return mxid
},
BoldConverter: func(text string) string {
return fmt.Sprintf("*%s*", text)
},
ItalicConverter: func(text string) string {
return fmt.Sprintf("_%s_", text)
},
StrikethroughConverter: func(text string) string {
return fmt.Sprintf("~%s~", text)
},
MonospaceConverter: func(text string) string {
return fmt.Sprintf("```%s```", text)
},
MonospaceBlockConverter: func(text string) string {
return fmt.Sprintf("```%s```", text)
},
}
func makeMessageID() string {
b := make([]byte, 10) b := make([]byte, 10)
rand.Read(b) rand.Read(b)
return strings.ToUpper(hex.EncodeToString(b)) str := strings.ToUpper(hex.EncodeToString(b))
return &str
} }
func (portal *Portal) PreprocessMatrixMedia(evt *gomatrix.Event) (string, io.ReadCloser, []byte) { func (portal *Portal) downloadThumbnail(evt *gomatrix.Event) []byte {
if evt.Content.Info == nil || len(evt.Content.Info.ThumbnailURL) == 0 {
return nil
}
thumbnail, err := portal.MainIntent().DownloadBytes(evt.Content.Info.ThumbnailURL)
if err != nil {
portal.log.Errorln("Failed to download thumbnail in %s: %v", evt.ID, err)
return nil
}
thumbnailType := http.DetectContentType(thumbnail)
var img image.Image
switch thumbnailType {
case "image/png":
img, err = png.Decode(bytes.NewReader(thumbnail))
case "image/gif":
img, err = gif.Decode(bytes.NewReader(thumbnail))
case "image/jpeg":
return thumbnail
default:
return nil
}
var buf bytes.Buffer
err = jpeg.Encode(&buf, img, &jpeg.Options{
Quality: jpeg.DefaultQuality,
})
if err != nil {
portal.log.Errorln("Failed to re-encode thumbnail in %s: %v", evt.ID, err)
return nil
}
return buf.Bytes()
}
func (portal *Portal) preprocessMatrixMedia(evt *gomatrix.Event, mediaType whatsapp.MediaType) *MediaUpload {
if evt.Content.Info == nil { if evt.Content.Info == nil {
evt.Content.Info = &gomatrix.FileInfo{} evt.Content.Info = &gomatrix.FileInfo{}
} }
@ -493,84 +487,184 @@ func (portal *Portal) PreprocessMatrixMedia(evt *gomatrix.Event) (string, io.Rea
break break
} }
} }
content, err := portal.MainIntent().Download(evt.Content.URL) content, err := portal.MainIntent().DownloadBytes(evt.Content.URL)
if err != nil { if err != nil {
portal.log.Errorln("Failed to download media in %s: %v", evt.ID, err) portal.log.Errorln("Failed to download media in %s: %v", evt.ID, err)
return "", nil, nil return nil
} }
thumbnail, err := portal.MainIntent().DownloadBytes(evt.Content.Info.ThumbnailURL)
return caption, content, thumbnail url, mediaKey, fileEncSHA256, fileSHA256, fileLength, err := portal.user.Conn.Upload(bytes.NewReader(content), mediaType)
if err != nil {
portal.log.Error("Failed to upload media in %s: %v", evt.ID, err)
return nil
}
return &MediaUpload{
Caption: caption,
URL: url,
MediaKey: mediaKey,
FileEncSHA256: fileEncSHA256,
FileSHA256: fileSHA256,
FileLength: fileLength,
Thumbnail: portal.downloadThumbnail(evt),
}
}
type MediaUpload struct {
Caption string
URL string
MediaKey []byte
FileEncSHA256 []byte
FileSHA256 []byte
FileLength uint64
Thumbnail []byte
}
func (portal *Portal) GetMessage(jid types.WhatsAppMessageID) *waProto.WebMessageInfo {
node, err := portal.user.Conn.LoadMessagesBefore(portal.JID, jid, 1)
if err != nil {
return nil
}
msgs, ok := node.Content.([]interface{})
if !ok {
return nil
}
msg, ok := msgs[0].(*waProto.WebMessageInfo)
if !ok {
return nil
}
node, err = portal.user.Conn.LoadMessagesAfter(portal.JID, msg.GetKey().GetId(), 1)
if err != nil {
return nil
}
msgs, ok = node.Content.([]interface{})
if !ok {
return nil
}
msg, _ = msgs[0].(*waProto.WebMessageInfo)
return msg
} }
func (portal *Portal) HandleMatrixMessage(evt *gomatrix.Event) { func (portal *Portal) HandleMatrixMessage(evt *gomatrix.Event) {
info := whatsapp.MessageInfo{ ts := uint64(evt.Timestamp / 1000)
Id: makeMessageID(), status := waProto.WebMessageInfo_ERROR
RemoteJid: portal.JID, fromMe := true
info := &waProto.WebMessageInfo{
Key: &waProto.MessageKey{
FromMe: &fromMe,
Id: makeMessageID(),
RemoteJid: &portal.JID,
},
MessageTimestamp: &ts,
Message: &waProto.Message{},
Status: &status,
}
ctxInfo := &waProto.ContextInfo{}
replyToID := evt.Content.GetReplyTo()
if len(replyToID) > 0 {
evt.Content.RemoveReplyFallback()
msg := portal.bridge.DB.Message.GetByMXID(replyToID)
if msg != nil {
origMsg := portal.GetMessage(msg.JID)
if origMsg != nil {
ctxInfo.StanzaId = &msg.JID
replyMsgSender := origMsg.GetParticipant()
if origMsg.GetKey().GetFromMe() {
replyMsgSender = portal.user.JID()
}
ctxInfo.Participant = &replyMsgSender
ctxInfo.QuotedMessage = []*waProto.Message{origMsg.Message}
}
}
} }
var err error var err error
switch evt.Content.MsgType { switch evt.Content.MsgType {
case gomatrix.MsgText, gomatrix.MsgEmote: case gomatrix.MsgText, gomatrix.MsgEmote:
text := evt.Content.Body text := evt.Content.Body
if evt.Content.Format == gomatrix.FormatHTML { if evt.Content.Format == gomatrix.FormatHTML {
text = htmlParser.Parse(evt.Content.FormattedBody) text = portal.user.htmlParser.Parse(evt.Content.FormattedBody)
} }
if evt.Content.MsgType == gomatrix.MsgEmote { if evt.Content.MsgType == gomatrix.MsgEmote {
text = "/me " + text text = "/me " + text
} }
err = portal.user.Conn.Send(whatsapp.TextMessage{ ctxInfo.MentionedJid = mentionRegex.FindAllString(text, -1)
Text: text, for index, mention := range ctxInfo.MentionedJid {
Info: info, ctxInfo.MentionedJid[index] = mention[1:] + whatsapp_ext.NewUserSuffix
}) }
if ctxInfo.StanzaId != nil || ctxInfo.MentionedJid != nil {
info.Message.ExtendedTextMessage = &waProto.ExtendedTextMessage{
Text: &text,
ContextInfo: ctxInfo,
}
} else {
info.Message.Conversation = &text
}
case gomatrix.MsgImage: case gomatrix.MsgImage:
caption, content, thumbnail := portal.PreprocessMatrixMedia(evt) media := portal.preprocessMatrixMedia(evt, whatsapp.MediaImage)
if content == nil { if media == nil {
return return
} }
err = portal.user.Conn.Send(whatsapp.ImageMessage{ info.Message.ImageMessage = &waProto.ImageMessage{
Caption: caption, Caption: &media.Caption,
Content: content, JpegThumbnail: media.Thumbnail,
Thumbnail: thumbnail, Url: &media.URL,
Type: evt.Content.Info.MimeType, MediaKey: media.MediaKey,
Info: info, Mimetype: &evt.Content.GetInfo().MimeType,
}) FileEncSha256: media.FileEncSHA256,
FileSha256: media.FileSHA256,
FileLength: &media.FileLength,
}
case gomatrix.MsgVideo: case gomatrix.MsgVideo:
caption, content, thumbnail := portal.PreprocessMatrixMedia(evt) media := portal.preprocessMatrixMedia(evt, whatsapp.MediaVideo)
if content == nil { if media == nil {
return return
} }
err = portal.user.Conn.Send(whatsapp.VideoMessage{ duration := uint32(evt.Content.GetInfo().Duration)
Caption: caption, info.Message.VideoMessage = &waProto.VideoMessage{
Content: content, Caption: &media.Caption,
Thumbnail: thumbnail, JpegThumbnail: media.Thumbnail,
Type: evt.Content.Info.MimeType, Url: &media.URL,
Info: info, MediaKey: media.MediaKey,
}) Mimetype: &evt.Content.GetInfo().MimeType,
Seconds: &duration,
FileEncSha256: media.FileEncSHA256,
FileSha256: media.FileSHA256,
FileLength: &media.FileLength,
}
case gomatrix.MsgAudio: case gomatrix.MsgAudio:
_, content, _ := portal.PreprocessMatrixMedia(evt) media := portal.preprocessMatrixMedia(evt, whatsapp.MediaAudio)
if content == nil { if media == nil {
return return
} }
err = portal.user.Conn.Send(whatsapp.AudioMessage{ duration := uint32(evt.Content.GetInfo().Duration)
Content: content, info.Message.AudioMessage = &waProto.AudioMessage{
Type: evt.Content.Info.MimeType, Url: &media.URL,
Info: info, MediaKey: media.MediaKey,
}) Mimetype: &evt.Content.GetInfo().MimeType,
Seconds: &duration,
FileEncSha256: media.FileEncSHA256,
FileSha256: media.FileSHA256,
FileLength: &media.FileLength,
}
case gomatrix.MsgFile: case gomatrix.MsgFile:
_, content, thumbnail := portal.PreprocessMatrixMedia(evt) media := portal.preprocessMatrixMedia(evt, whatsapp.MediaDocument)
if content == nil { if media == nil {
return return
} }
err = portal.user.Conn.Send(whatsapp.DocumentMessage{ info.Message.DocumentMessage = &waProto.DocumentMessage{
Content: content, Url: &media.URL,
Thumbnail: thumbnail, MediaKey: media.MediaKey,
Type: evt.Content.Info.MimeType, Mimetype: &evt.Content.GetInfo().MimeType,
Info: info, FileEncSha256: media.FileEncSHA256,
}) FileSha256: media.FileSHA256,
FileLength: &media.FileLength,
}
default: default:
portal.log.Debugln("Unhandled Matrix event:", evt) portal.log.Debugln("Unhandled Matrix event:", evt)
return return
} }
portal.MarkHandled(info.Id, evt.ID) err = portal.user.Conn.Send(info)
portal.MarkHandled(info.GetKey().GetId(), evt.ID)
if err != nil { if err != nil {
portal.log.Errorfln("Error handling Matrix event %s: %v", evt.ID, err) portal.log.Errorfln("Error handling Matrix event %s: %v", evt.ID, err)
} else { } else {

View file

@ -32,7 +32,7 @@ import (
func (bridge *Bridge) ParsePuppetMXID(mxid types.MatrixUserID) (types.MatrixUserID, types.WhatsAppID, bool) { func (bridge *Bridge) ParsePuppetMXID(mxid types.MatrixUserID) (types.MatrixUserID, types.WhatsAppID, bool) {
userIDRegex, err := regexp.Compile(fmt.Sprintf("^@%s:%s$", userIDRegex, err := regexp.Compile(fmt.Sprintf("^@%s:%s$",
bridge.Config.Bridge.FormatUsername("([0-9]+)", "([0-9]+)"), bridge.Config.Bridge.FormatUsername("(.+)", "([0-9]+)"),
bridge.Config.Homeserver.Domain)) bridge.Config.Homeserver.Domain))
if err != nil { if err != nil {
bridge.Log.Warnln("Failed to compile puppet user ID regex:", err) bridge.Log.Warnln("Failed to compile puppet user ID regex:", err)
@ -138,6 +138,10 @@ type Puppet struct {
MXID types.MatrixUserID MXID types.MatrixUserID
} }
func (puppet *Puppet) PhoneNumber() string {
return strings.Replace(puppet.JID, whatsapp_ext.NewUserSuffix, "", 1)
}
func (puppet *Puppet) Intent() *appservice.IntentAPI { func (puppet *Puppet) Intent() *appservice.IntentAPI {
return puppet.bridge.AppService.Intent(puppet.MXID) return puppet.bridge.AppService.Intent(puppet.MXID)
} }

20
user.go
View file

@ -17,12 +17,14 @@
package main package main
import ( import (
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/Rhymen/go-whatsapp" "github.com/Rhymen/go-whatsapp"
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"maunium.net/go/gomatrix/format"
log "maunium.net/go/maulogger" log "maunium.net/go/maulogger"
"maunium.net/go/mautrix-whatsapp/database" "maunium.net/go/mautrix-whatsapp/database"
"maunium.net/go/mautrix-whatsapp/types" "maunium.net/go/mautrix-whatsapp/types"
@ -41,6 +43,11 @@ type User struct {
portalsLock sync.Mutex portalsLock sync.Mutex
puppets map[types.WhatsAppID]*Puppet puppets map[types.WhatsAppID]*Puppet
puppetsLock sync.Mutex puppetsLock sync.Mutex
htmlParser *format.HTMLParser
waReplString map[*regexp.Regexp]string
waReplFunc map[*regexp.Regexp]func(string) string
} }
func (bridge *Bridge) GetUser(userID types.MatrixUserID) *User { func (bridge *Bridge) GetUser(userID types.MatrixUserID) *User {
@ -79,7 +86,7 @@ func (bridge *Bridge) GetAllUsers() []*User {
} }
func (bridge *Bridge) NewUser(dbUser *database.User) *User { func (bridge *Bridge) NewUser(dbUser *database.User) *User {
return &User{ user := &User{
User: dbUser, User: dbUser,
bridge: bridge, bridge: bridge,
log: bridge.Log.Sub("User").Sub(string(dbUser.ID)), log: bridge.Log.Sub("User").Sub(string(dbUser.ID)),
@ -87,6 +94,9 @@ func (bridge *Bridge) NewUser(dbUser *database.User) *User {
portalsByJID: make(map[types.WhatsAppID]*Portal), portalsByJID: make(map[types.WhatsAppID]*Portal),
puppets: make(map[types.WhatsAppID]*Puppet), puppets: make(map[types.WhatsAppID]*Puppet),
} }
user.htmlParser = user.newHTMLParser()
user.waReplString, user.waReplFunc = user.newWhatsAppFormatMaps()
return user
} }
func (user *User) SetManagementRoom(roomID types.MatrixRoomID) { func (user *User) SetManagementRoom(roomID types.MatrixRoomID) {
@ -183,6 +193,10 @@ func (user *User) Login(roomID types.MatrixRoomID) {
go user.Sync() go user.Sync()
} }
func (user *User) JID() string {
return strings.Replace(user.Conn.Info.Wid, whatsapp_ext.OldUserSuffix, whatsapp_ext.NewUserSuffix, 1)
}
func (user *User) Sync() { func (user *User) Sync() {
user.log.Debugln("Syncing...") user.log.Debugln("Syncing...")
user.Conn.Contacts() user.Conn.Contacts()
@ -241,7 +255,7 @@ func (user *User) HandlePresence(info whatsapp_ext.Presence) {
case whatsapp_ext.PresenceUnavailable: case whatsapp_ext.PresenceUnavailable:
puppet.Intent().SetPresence("offline") puppet.Intent().SetPresence("offline")
case whatsapp_ext.PresenceAvailable: case whatsapp_ext.PresenceAvailable:
if len(puppet.typingIn) > 0 && puppet.typingAt + 15 > time.Now().Unix() { if len(puppet.typingIn) > 0 && puppet.typingAt+15 > time.Now().Unix() {
puppet.Intent().UserTyping(puppet.typingIn, false, 0) puppet.Intent().UserTyping(puppet.typingIn, false, 0)
puppet.typingIn = "" puppet.typingIn = ""
puppet.typingAt = 0 puppet.typingAt = 0
@ -252,7 +266,7 @@ func (user *User) HandlePresence(info whatsapp_ext.Presence) {
portal := user.GetPortalByJID(info.JID) portal := user.GetPortalByJID(info.JID)
puppet.typingIn = portal.MXID puppet.typingIn = portal.MXID
puppet.typingAt = time.Now().Unix() puppet.typingAt = time.Now().Unix()
puppet.Intent().UserTyping(portal.MXID, true, 15 * 1000) puppet.Intent().UserTyping(portal.MXID, true, 15*1000)
} }
} }