Skip to content

Commit

Permalink
Add support for incoming WhatsApp text messages
Browse files Browse the repository at this point in the history
  • Loading branch information
tulir committed Feb 6, 2024
1 parent a279af7 commit c62503c
Show file tree
Hide file tree
Showing 4 changed files with 265 additions and 8 deletions.
9 changes: 9 additions & 0 deletions messagix/table/enums.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,15 @@ func (tt ThreadType) IsOneToOne() bool {
}
}

func (tt ThreadType) IsWhatsApp() bool {
switch tt {
case ENCRYPTED_OVER_WA_GROUP, ENCRYPTED_OVER_WA_ONE_TO_ONE:
return true
default:
return false
}
}

const (
UNKNOWN_THREAD_TYPE ThreadType = 0
ONE_TO_ONE ThreadType = 1
Expand Down
132 changes: 132 additions & 0 deletions msgconv/from-whatsapp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// mautrix-meta - A Matrix-Facebook Messenger and Instagram DM puppeting bridge.
// Copyright (C) 2024 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 msgconv

import (
"context"
"fmt"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"slices"
"strings"

"github.com/rs/zerolog"
"go.mau.fi/whatsmeow/binary/armadillo/waCommon"
"go.mau.fi/whatsmeow/binary/armadillo/waConsumerApplication"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
_ "golang.org/x/image/webp"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)

func (mc *MessageConverter) whatsappTextToMatrix(ctx context.Context, text *waCommon.MessageText) *ConvertedMessagePart {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text.GetText(),
Mentions: &event.Mentions{},
}
silent := false
if len(text.Commands) > 0 {
for _, cmd := range text.Commands {
switch cmd.CommandType {
case waCommon.Command_SILENT:
silent = true
content.Mentions.Room = false
case waCommon.Command_EVERYONE:
if !silent {
content.Mentions.Room = true
}
case waCommon.Command_AI:
// TODO ???
}
}
}
if len(text.GetMentionedJID()) > 0 {
content.Format = event.FormatHTML
content.FormattedBody = event.TextToHTML(content.Body)
for _, jid := range text.GetMentionedJID() {
parsed, err := types.ParseJID(jid)
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to parse mentioned JID")
continue
}
mxid := mc.GetUserMXID(ctx, int64(parsed.UserInt()))
if !silent {
content.Mentions.UserIDs = append(content.Mentions.UserIDs, mxid)
}
mentionText := "@" + jid
content.Body = strings.ReplaceAll(content.Body, mentionText, mxid.String())
content.FormattedBody = strings.ReplaceAll(content.FormattedBody, mentionText, fmt.Sprintf(`<a href="%s">%s</a>`, mxid.URI().MatrixToURL(), mxid.String()))
}
}
return &ConvertedMessagePart{
Type: event.EventMessage,
Content: content,
}
}

func (mc *MessageConverter) WhatsAppToMatrix(ctx context.Context, evt *events.FBConsumerMessage) *ConvertedMessage {
cm := &ConvertedMessage{
Parts: make([]*ConvertedMessagePart, 0),
}
switch content := evt.Message.GetPayload().GetContent().GetContent().(type) {
case *waConsumerApplication.ConsumerApplication_Content_MessageText:
cm.Parts = append(cm.Parts, mc.whatsappTextToMatrix(ctx, content.MessageText))
case *waConsumerApplication.ConsumerApplication_Content_ExtendedTextMessage:
// TODO convert url previews
cm.Parts = append(cm.Parts, mc.whatsappTextToMatrix(ctx, content.ExtendedTextMessage.GetText()))
case *waConsumerApplication.ConsumerApplication_Content_ImageMessage:
case *waConsumerApplication.ConsumerApplication_Content_StickerMessage:
case *waConsumerApplication.ConsumerApplication_Content_ViewOnceMessage:
case *waConsumerApplication.ConsumerApplication_Content_DocumentMessage:
case *waConsumerApplication.ConsumerApplication_Content_AudioMessage:
case *waConsumerApplication.ConsumerApplication_Content_VideoMessage:
case *waConsumerApplication.ConsumerApplication_Content_LocationMessage:
case *waConsumerApplication.ConsumerApplication_Content_LiveLocationMessage:
case *waConsumerApplication.ConsumerApplication_Content_ContactMessage:
case *waConsumerApplication.ConsumerApplication_Content_ContactsArrayMessage:
}
if len(cm.Parts) == 0 {
cm.Parts = append(cm.Parts, &ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Unsupported message",
},
})
}
var replyTo id.EventID
var sender id.UserID
if qm := evt.Application.GetMetadata().GetQuotedMessage(); qm != nil {
pcp, _ := types.ParseJID(qm.GetParticipant())
replyTo, sender = mc.GetMatrixReply(ctx, qm.GetStanzaID(), int64(pcp.UserInt()))
}
for _, part := range cm.Parts {
if part.Content.Mentions == nil {
part.Content.Mentions = &event.Mentions{}
}
if replyTo != "" {
part.Content.RelatesTo = (&event.RelatesTo{}).SetReplyTo(replyTo)
if !slices.Contains(part.Content.Mentions.UserIDs, sender) {
part.Content.Mentions.UserIDs = append(part.Content.Mentions.UserIDs, sender)
}
}
}
return cm
}
131 changes: 123 additions & 8 deletions portal.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ import (
"errors"
"fmt"
"reflect"
"slices"
"strconv"
"sync"
"sync/atomic"
"time"

"github.com/rs/zerolog"
"go.mau.fi/util/variationselector"
"go.mau.fi/whatsmeow/binary/armadillo/waConsumerApplication"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/bridge"
Expand Down Expand Up @@ -326,6 +330,10 @@ func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) {
bridgeInfo.Protocol.ExternalURL = "https://www.messenger.com/"
bridgeInfo.Channel.ExternalURL = fmt.Sprintf("https://www.messenger.com/t/%d", portal.ThreadID)
}
if portal.ThreadType.IsWhatsApp() {
// TODO store fb-side thread ID? (the whatsapp chat id is not the same as the fb-side thread id used in urls)
bridgeInfo.Channel.ExternalURL = ""
}
var roomType string
if portal.IsPrivateChat() {
roomType = "dm"
Expand Down Expand Up @@ -847,7 +855,7 @@ func (portal *Portal) GetMatrixReply(ctx context.Context, replyToID string, repl
}
} else {
replyTo = message.MXID
if message.Sender != replyToUser {
if replyToUser != 0 && message.Sender != replyToUser {
log.Warn().
Int64("message_sender", message.Sender).
Int64("reply_to_user", replyToUser).
Expand Down Expand Up @@ -898,6 +906,8 @@ func (portal *Portal) GetUserMXID(ctx context.Context, userID int64) id.UserID {

func (portal *Portal) handleMetaMessage(portalMessage portalMetaMessage) {
switch typedEvt := portalMessage.evt.(type) {
case *events.FBConsumerMessage:
portal.handleEncryptedMessage(portalMessage.user, typedEvt)
case *table.WrappedMessage:
portal.handleMetaInsertMessage(portalMessage.user, typedEvt)
case *table.UpsertMessages:
Expand Down Expand Up @@ -955,6 +965,41 @@ func (portal *Portal) checkPendingMessage(ctx context.Context, messageID string,
return true
}

func (portal *Portal) handleEncryptedMessage(source *User, evt *events.FBConsumerMessage) {
sender := portal.bridge.GetPuppetByID(int64(evt.Info.Sender.UserInt()))
log := portal.log.With().
Str("action", "handle whatsapp message").
Stringer("chat_jid", evt.Info.Chat).
Stringer("sender_jid", evt.Info.Sender).
Str("message_id", evt.Info.ID).
Logger()
ctx := log.WithContext(context.TODO())

switch payload := evt.Message.GetPayload().GetPayload().(type) {
case *waConsumerApplication.ConsumerApplication_Payload_Content:
switch payload.Content.GetContent().(type) {
case *waConsumerApplication.ConsumerApplication_Content_EditMessage:
log.Warn().Msg("Unsupported edit message payload message")
case *waConsumerApplication.ConsumerApplication_Content_ReactionMessage:
log.Warn().Msg("Unsupported reaction message payload message")
default:
portal.handleMetaOrWhatsAppMessage(ctx, source, sender, evt, nil)
}
case *waConsumerApplication.ConsumerApplication_Payload_ApplicationData:
switch applicationContent := payload.ApplicationData.GetApplicationContent().(type) {
case *waConsumerApplication.ConsumerApplication_ApplicationData_Revoke:
default:
log.Warn().Type("content_type", applicationContent).Msg("Unrecognized application content type")
}
case *waConsumerApplication.ConsumerApplication_Payload_Signal:
log.Warn().Msg("Unsupported signal payload message")
case *waConsumerApplication.ConsumerApplication_Payload_SubProtocol:
log.Warn().Msg("Unsupported subprotocol payload message")
default:
log.Warn().Type("payload_type", payload).Msg("Unrecognized payload type")
}
}

func (portal *Portal) handleMetaInsertMessage(source *User, message *table.WrappedMessage) {
sender := portal.bridge.GetPuppetByID(message.SenderId)
log := portal.log.With().
Expand All @@ -964,6 +1009,11 @@ func (portal *Portal) handleMetaInsertMessage(source *User, message *table.Wrapp
Str("otid", message.OfflineThreadingId).
Logger()
ctx := log.WithContext(context.TODO())
portal.handleMetaOrWhatsAppMessage(ctx, source, sender, nil, message)
}

func (portal *Portal) handleMetaOrWhatsAppMessage(ctx context.Context, source *User, sender *Puppet, waMsg *events.FBConsumerMessage, metaMsg *table.WrappedMessage) {
log := zerolog.Ctx(ctx)

if portal.MXID == "" {
log.Debug().Msg("Creating Matrix room from incoming message")
Expand All @@ -973,13 +1023,22 @@ func (portal *Portal) handleMetaInsertMessage(source *User, message *table.Wrapp
}
}

otidInt, _ := strconv.ParseInt(message.OfflineThreadingId, 10, 64)
messageTime := time.UnixMilli(message.TimestampMs)
if portal.checkPendingMessage(ctx, message.MessageId, otidInt, sender.ID, messageTime) {
return
var messageID string
var messageTime time.Time
var otidInt int64
if waMsg != nil {
messageID = waMsg.Info.ID
messageTime = waMsg.Info.Timestamp
} else {
messageID = metaMsg.MessageId
otidInt, _ = strconv.ParseInt(metaMsg.OfflineThreadingId, 10, 64)
messageTime = time.UnixMilli(metaMsg.TimestampMs)
if portal.checkPendingMessage(ctx, metaMsg.MessageId, otidInt, sender.ID, messageTime) {
return
}
}

existingMessage, err := portal.bridge.DB.Message.GetByID(ctx, message.MessageId, 0, portal.Receiver)
existingMessage, err := portal.bridge.DB.Message.GetByID(ctx, messageID, 0, portal.Receiver)
if err != nil {
log.Err(err).Msg("Failed to check if message was already bridged")
return
Expand All @@ -991,7 +1050,12 @@ func (portal *Portal) handleMetaInsertMessage(source *User, message *table.Wrapp
intent := sender.IntentFor(portal)
ctx = context.WithValue(ctx, msgconvContextKeyIntent, intent)
ctx = context.WithValue(ctx, msgconvContextKeyClient, source.Client)
converted := portal.MsgConv.ToMatrix(ctx, message)
var converted *msgconv.ConvertedMessage
if waMsg != nil {
converted = portal.MsgConv.WhatsAppToMatrix(ctx, waMsg)
} else {
converted = portal.MsgConv.ToMatrix(ctx, metaMsg)
}
if portal.bridge.Config.Bridge.CaptionInMessage {
converted.MergeCaption()
}
Expand All @@ -1005,7 +1069,7 @@ func (portal *Portal) handleMetaInsertMessage(source *User, message *table.Wrapp
log.Err(err).Int("part_index", i).Msg("Failed to send message to Matrix")
continue
}
portal.storeMessageInDB(ctx, resp.EventID, message.MessageId, otidInt, sender.ID, messageTime, i)
portal.storeMessageInDB(ctx, resp.EventID, messageID, otidInt, sender.ID, messageTime, i)
}
}

Expand Down Expand Up @@ -1389,6 +1453,14 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User) error {
if autoJoinInvites {
invite = append(invite, user.MXID)
}
var waGroupInfo *types.GroupInfo
var participants []id.UserID
if portal.ThreadType == table.ENCRYPTED_OVER_WA_GROUP {
waGroupInfo, participants = portal.UpdateWAGroupInfo(ctx, user, nil)
invite = append(invite, participants...)
slices.Sort(invite)
invite = slices.Compact(invite)
}

if portal.bridge.Config.Bridge.Encryption.Default {
initialState = append(initialState, &event.Event{
Expand Down Expand Up @@ -1461,6 +1533,9 @@ func (portal *Portal) CreateMatrixRoom(ctx context.Context, user *User) error {
if portal.IsPrivateChat() {
user.AddDirectChat(ctx, portal.MXID, portal.GetDMPuppet().MXID)
}
if waGroupInfo != nil && !autoJoinInvites {
portal.SyncWAParticipants(ctx, waGroupInfo.Participants)
}

return nil
}
Expand All @@ -1483,6 +1558,46 @@ func (portal *Portal) UpdateInfoFromPuppet(ctx context.Context, puppet *Puppet)
}
}

func (portal *Portal) UpdateWAGroupInfo(ctx context.Context, source *User, groupInfo *types.GroupInfo) (*types.GroupInfo, []id.UserID) {
log := zerolog.Ctx(ctx)
if groupInfo == nil {
var err error
groupInfo, err = source.E2EEClient.GetGroupInfo(portal.JID())
if err != nil {
log.Err(err).Msg("Failed to fetch WhatsApp group info")
return nil, nil
}
}
update := false
update = portal.updateName(ctx, groupInfo.Name) || update
//update = portal.updateTopic(ctx, groupInfo.Topic) || update
//update = portal.updateWAAvatar(ctx)
participants := portal.SyncWAParticipants(ctx, groupInfo.Participants)
if update {
err := portal.Update(ctx)
if err != nil {
log.Err(err).Msg("Failed to save portal in database after updating group info")
}
portal.UpdateBridgeInfo(ctx)
}
return groupInfo, participants
}

func (portal *Portal) SyncWAParticipants(ctx context.Context, participants []types.GroupParticipant) []id.UserID {
var userIDs []id.UserID
for _, pcp := range participants {
puppet := portal.bridge.GetPuppetByID(int64(pcp.JID.UserInt()))
userIDs = append(userIDs, puppet.IntentFor(portal).UserID)
if portal.MXID != "" {
err := puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to ensure participant is joined to group")
}
}
}
return userIDs
}

func (portal *Portal) UpdateInfo(ctx context.Context, info table.ThreadInfo) {
log := zerolog.Ctx(ctx).With().
Str("function", "UpdateInfo").
Expand Down
1 change: 1 addition & 0 deletions user.go
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,7 @@ func (user *User) e2eeEventHandler(rawEvt any) {
log.Err(err).Msg("Failed to update portal")
}
}
portal.metaMessages <- portalMetaMessage{user: user, evt: evt}
}
}

Expand Down

0 comments on commit c62503c

Please sign in to comment.