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 . + +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$2$3", + boldRegex: "$1$2$3", + strikethroughRegex: "$1$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$2$3", -} - func (portal *Portal) ParseWhatsAppFormat(input string) string { output := html.EscapeString(input) - for regex, replacement := range whatsAppFormat { + for regex, replacement := range portal.user.waReplString { output = regex.ReplaceAllString(output, replacement) } - output = codeBlockRegex.ReplaceAllStringFunc(output, 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) - }) + 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) } }