diff --git a/ROADMAP.md b/ROADMAP.md
index 9995536..c5cae8d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,6 +4,7 @@
* [x] Plain text
* [x] Formatted messages
* [x] Media/files
+ * [x] Replies
* [ ] Message redactions
* [ ] Presence
* [ ] Typing notifications
@@ -24,6 +25,7 @@
* [x] Plain text
* [x] Formatted messages
* [x] Media/files
+ * [x] Replies
* [ ] Message deletions
* [x] Avatars
* [x] Presence
diff --git a/formatting.go b/formatting.go
new file mode 100644
index 0000000..fc3bc9e
--- /dev/null
+++ b/formatting.go
@@ -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 $2$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("
%s
", str)
+ }
+ return fmt.Sprintf("%s
", str)
+ },
+ mentionRegex: func(str string) string {
+ jid := str[1:] + whatsapp_ext.NewUserSuffix
+ puppet := user.GetPuppetByJID(jid)
+ return fmt.Sprintf(`%s`, puppet.MXID, puppet.Displayname)
+ },
+ }
+}
diff --git a/portal.go b/portal.go
index a32e2fc..135e25d 100644
--- a/portal.go
+++ b/portal.go
@@ -22,17 +22,18 @@ import (
"fmt"
"html"
"image"
- "io"
+ "image/gif"
+ "image/jpeg"
+ "image/png"
"math/rand"
"mime"
"net/http"
- "regexp"
"strings"
"sync"
"github.com/Rhymen/go-whatsapp"
+ waProto "github.com/Rhymen/go-whatsapp/binary/proto"
"maunium.net/go/gomatrix"
- "maunium.net/go/gomatrix/format"
log "maunium.net/go/maulogger"
"maunium.net/go/mautrix-appservice"
"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 {
if info.FromMe {
- portal.log.Debugln("Unhandled message from me:", info.Id)
- return nil
+ return portal.user.GetPuppetByJID(portal.user.JID()).Intent()
} else if portal.IsPrivateChat() {
return portal.MainIntent()
}
- puppet := portal.user.GetPuppetByJID(info.SenderJid)
- return puppet.Intent()
+ return portal.user.GetPuppetByJID(info.SenderJid).Intent()
}
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
}
-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$2$3",
- boldRegex: "$1$2$3",
- strikethroughRegex: "$1%s
", str)
- }
- return fmt.Sprintf("%s
", str)
- })
+ for regex, replacer := range portal.user.waReplFunc {
+ output = regex.ReplaceAllStringFunc(output, replacer)
+ }
return output
}
@@ -451,37 +435,47 @@ func (portal *Portal) HandleMediaMessage(download func() ([]byte, error), thumbn
portal.log.Debugln("Handled message", info.Id, "->", resp.EventID)
}
-var htmlParser = format.HTMLParser{
- 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 {
+func makeMessageID() *string {
b := make([]byte, 10)
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 {
evt.Content.Info = &gomatrix.FileInfo{}
}
@@ -493,84 +487,184 @@ func (portal *Portal) PreprocessMatrixMedia(evt *gomatrix.Event) (string, io.Rea
break
}
}
- content, err := portal.MainIntent().Download(evt.Content.URL)
+ content, err := portal.MainIntent().DownloadBytes(evt.Content.URL)
if err != nil {
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) {
- info := whatsapp.MessageInfo{
- Id: makeMessageID(),
- RemoteJid: portal.JID,
+ ts := uint64(evt.Timestamp / 1000)
+ status := waProto.WebMessageInfo_ERROR
+ 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
switch evt.Content.MsgType {
case gomatrix.MsgText, gomatrix.MsgEmote:
text := evt.Content.Body
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 {
text = "/me " + text
}
- err = portal.user.Conn.Send(whatsapp.TextMessage{
- Text: text,
- Info: info,
- })
+ ctxInfo.MentionedJid = mentionRegex.FindAllString(text, -1)
+ for index, mention := range ctxInfo.MentionedJid {
+ 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:
- caption, content, thumbnail := portal.PreprocessMatrixMedia(evt)
- if content == nil {
+ media := portal.preprocessMatrixMedia(evt, whatsapp.MediaImage)
+ if media == nil {
return
}
- err = portal.user.Conn.Send(whatsapp.ImageMessage{
- Caption: caption,
- Content: content,
- Thumbnail: thumbnail,
- Type: evt.Content.Info.MimeType,
- Info: info,
- })
+ info.Message.ImageMessage = &waProto.ImageMessage{
+ Caption: &media.Caption,
+ JpegThumbnail: media.Thumbnail,
+ Url: &media.URL,
+ MediaKey: media.MediaKey,
+ Mimetype: &evt.Content.GetInfo().MimeType,
+ FileEncSha256: media.FileEncSHA256,
+ FileSha256: media.FileSHA256,
+ FileLength: &media.FileLength,
+ }
case gomatrix.MsgVideo:
- caption, content, thumbnail := portal.PreprocessMatrixMedia(evt)
- if content == nil {
+ media := portal.preprocessMatrixMedia(evt, whatsapp.MediaVideo)
+ if media == nil {
return
}
- err = portal.user.Conn.Send(whatsapp.VideoMessage{
- Caption: caption,
- Content: content,
- Thumbnail: thumbnail,
- Type: evt.Content.Info.MimeType,
- Info: info,
- })
+ duration := uint32(evt.Content.GetInfo().Duration)
+ info.Message.VideoMessage = &waProto.VideoMessage{
+ Caption: &media.Caption,
+ JpegThumbnail: media.Thumbnail,
+ Url: &media.URL,
+ MediaKey: media.MediaKey,
+ Mimetype: &evt.Content.GetInfo().MimeType,
+ Seconds: &duration,
+ FileEncSha256: media.FileEncSHA256,
+ FileSha256: media.FileSHA256,
+ FileLength: &media.FileLength,
+ }
case gomatrix.MsgAudio:
- _, content, _ := portal.PreprocessMatrixMedia(evt)
- if content == nil {
+ media := portal.preprocessMatrixMedia(evt, whatsapp.MediaAudio)
+ if media == nil {
return
}
- err = portal.user.Conn.Send(whatsapp.AudioMessage{
- Content: content,
- Type: evt.Content.Info.MimeType,
- Info: info,
- })
+ duration := uint32(evt.Content.GetInfo().Duration)
+ info.Message.AudioMessage = &waProto.AudioMessage{
+ Url: &media.URL,
+ MediaKey: media.MediaKey,
+ Mimetype: &evt.Content.GetInfo().MimeType,
+ Seconds: &duration,
+ FileEncSha256: media.FileEncSHA256,
+ FileSha256: media.FileSHA256,
+ FileLength: &media.FileLength,
+ }
case gomatrix.MsgFile:
- _, content, thumbnail := portal.PreprocessMatrixMedia(evt)
- if content == nil {
+ media := portal.preprocessMatrixMedia(evt, whatsapp.MediaDocument)
+ if media == nil {
return
}
- err = portal.user.Conn.Send(whatsapp.DocumentMessage{
- Content: content,
- Thumbnail: thumbnail,
- Type: evt.Content.Info.MimeType,
- Info: info,
- })
+ info.Message.DocumentMessage = &waProto.DocumentMessage{
+ Url: &media.URL,
+ MediaKey: media.MediaKey,
+ Mimetype: &evt.Content.GetInfo().MimeType,
+ FileEncSha256: media.FileEncSHA256,
+ FileSha256: media.FileSHA256,
+ FileLength: &media.FileLength,
+ }
default:
portal.log.Debugln("Unhandled Matrix event:", evt)
return
}
- portal.MarkHandled(info.Id, evt.ID)
+ err = portal.user.Conn.Send(info)
+ portal.MarkHandled(info.GetKey().GetId(), evt.ID)
if err != nil {
portal.log.Errorfln("Error handling Matrix event %s: %v", evt.ID, err)
} else {
diff --git a/puppet.go b/puppet.go
index c90ddf2..d59e59c 100644
--- a/puppet.go
+++ b/puppet.go
@@ -32,7 +32,7 @@ import (
func (bridge *Bridge) ParsePuppetMXID(mxid types.MatrixUserID) (types.MatrixUserID, types.WhatsAppID, bool) {
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))
if err != nil {
bridge.Log.Warnln("Failed to compile puppet user ID regex:", err)
@@ -138,6 +138,10 @@ type Puppet struct {
MXID types.MatrixUserID
}
+func (puppet *Puppet) PhoneNumber() string {
+ return strings.Replace(puppet.JID, whatsapp_ext.NewUserSuffix, "", 1)
+}
+
func (puppet *Puppet) Intent() *appservice.IntentAPI {
return puppet.bridge.AppService.Intent(puppet.MXID)
}
diff --git a/user.go b/user.go
index cd36e51..98b8c86 100644
--- a/user.go
+++ b/user.go
@@ -17,12 +17,14 @@
package main
import (
+ "regexp"
"strings"
"sync"
"time"
"github.com/Rhymen/go-whatsapp"
"github.com/skip2/go-qrcode"
+ "maunium.net/go/gomatrix/format"
log "maunium.net/go/maulogger"
"maunium.net/go/mautrix-whatsapp/database"
"maunium.net/go/mautrix-whatsapp/types"
@@ -41,6 +43,11 @@ type User struct {
portalsLock sync.Mutex
puppets map[types.WhatsAppID]*Puppet
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 {
@@ -79,7 +86,7 @@ func (bridge *Bridge) GetAllUsers() []*User {
}
func (bridge *Bridge) NewUser(dbUser *database.User) *User {
- return &User{
+ user := &User{
User: dbUser,
bridge: bridge,
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),
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) {
@@ -183,6 +193,10 @@ func (user *User) Login(roomID types.MatrixRoomID) {
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() {
user.log.Debugln("Syncing...")
user.Conn.Contacts()
@@ -241,7 +255,7 @@ func (user *User) HandlePresence(info whatsapp_ext.Presence) {
case whatsapp_ext.PresenceUnavailable:
puppet.Intent().SetPresence("offline")
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.typingIn = ""
puppet.typingAt = 0
@@ -252,7 +266,7 @@ func (user *User) HandlePresence(info whatsapp_ext.Presence) {
portal := user.GetPortalByJID(info.JID)
puppet.typingIn = portal.MXID
puppet.typingAt = time.Now().Unix()
- puppet.Intent().UserTyping(portal.MXID, true, 15 * 1000)
+ puppet.Intent().UserTyping(portal.MXID, true, 15*1000)
}
}