mautrix-whatsapp/portal.go
Nicolas Werner fc4098d3e4 Receive stickers from whatsapp
We need to convert them to png, otherwise synapse has trouble
thumbnailing them. Also the default webp decoder can't decode WhatsApp
stickers, so we use the chai2010 decoder.
2019-10-05 02:39:55 +02:00

1377 lines
38 KiB
Go

// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
// Copyright (C) 2019 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 (
"bytes"
"encoding/gob"
"encoding/hex"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"math/rand"
"mime"
"net/http"
"reflect"
"strings"
"sync"
"time"
"github.com/chai2010/webp"
"github.com/Rhymen/go-whatsapp"
waProto "github.com/Rhymen/go-whatsapp/binary/proto"
"maunium.net/go/mautrix/format"
log "maunium.net/go/maulogger/v2"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix-appservice"
"maunium.net/go/mautrix-whatsapp/database"
"maunium.net/go/mautrix-whatsapp/types"
"maunium.net/go/mautrix-whatsapp/whatsapp-ext"
)
func (bridge *Bridge) GetPortalByMXID(mxid types.MatrixRoomID) *Portal {
bridge.portalsLock.Lock()
defer bridge.portalsLock.Unlock()
portal, ok := bridge.portalsByMXID[mxid]
if !ok {
return bridge.loadDBPortal(bridge.DB.Portal.GetByMXID(mxid), nil)
}
return portal
}
func (bridge *Bridge) GetPortalByJID(key database.PortalKey) *Portal {
bridge.portalsLock.Lock()
defer bridge.portalsLock.Unlock()
portal, ok := bridge.portalsByJID[key]
if !ok {
return bridge.loadDBPortal(bridge.DB.Portal.GetByJID(key), &key)
}
return portal
}
func (bridge *Bridge) GetAllPortals() []*Portal {
return bridge.dbPortalsToPortals(bridge.DB.Portal.GetAll())
}
func (bridge *Bridge) GetAllPortalsByJID(jid types.WhatsAppID) []*Portal {
return bridge.dbPortalsToPortals(bridge.DB.Portal.GetAllByJID(jid))
}
func (bridge *Bridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal {
bridge.portalsLock.Lock()
defer bridge.portalsLock.Unlock()
output := make([]*Portal, len(dbPortals))
for index, dbPortal := range dbPortals {
if dbPortal == nil {
continue
}
portal, ok := bridge.portalsByJID[dbPortal.Key]
if !ok {
portal = bridge.loadDBPortal(dbPortal, nil)
}
output[index] = portal
}
return output
}
func (bridge *Bridge) loadDBPortal(dbPortal *database.Portal, key *database.PortalKey) *Portal {
if dbPortal == nil {
if key == nil {
return nil
}
dbPortal = bridge.DB.Portal.New()
dbPortal.Key = *key
dbPortal.Insert()
}
portal := bridge.NewPortal(dbPortal)
bridge.portalsByJID[portal.Key] = portal
if len(portal.MXID) > 0 {
bridge.portalsByMXID[portal.MXID] = portal
}
return portal
}
func (portal *Portal) GetUsers() []*User {
return nil
}
func (bridge *Bridge) NewPortal(dbPortal *database.Portal) *Portal {
portal := &Portal{
Portal: dbPortal,
bridge: bridge,
log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)),
recentlyHandled: [recentlyHandledLength]types.WhatsAppMessageID{},
messages: make(chan PortalMessage, 128),
}
go portal.handleMessageLoop()
return portal
}
const recentlyHandledLength = 100
type PortalMessage struct {
chat string
source *User
data interface{}
timestamp uint64
}
type Portal struct {
*database.Portal
bridge *Bridge
log log.Logger
roomCreateLock sync.Mutex
recentlyHandled [recentlyHandledLength]types.WhatsAppMessageID
recentlyHandledLock sync.Mutex
recentlyHandledIndex uint8
backfillLock sync.Mutex
backfilling bool
lastMessageTs uint64
privateChatBackfillInvitePuppet func()
messages chan PortalMessage
isPrivate *bool
}
const MaxMessageAgeToCreatePortal = 5 * 60 // 5 minutes
func (portal *Portal) handleMessageLoop() {
for msg := range portal.messages {
if len(portal.MXID) == 0 {
if msg.timestamp+MaxMessageAgeToCreatePortal < uint64(time.Now().Unix()) {
portal.log.Debugln("Not creating portal room for incoming message as the message is too old.")
continue
}
err := portal.CreateMatrixRoom(msg.source)
if err != nil {
portal.log.Errorln("Failed to create portal room:", err)
return
}
}
portal.backfillLock.Lock()
portal.handleMessage(msg)
portal.backfillLock.Unlock()
}
}
func (portal *Portal) handleMessage(msg PortalMessage) {
if len(portal.MXID) == 0 {
portal.log.Warnln("handleMessage called even though portal.MXID is empty")
return
}
switch data := msg.data.(type) {
case whatsapp.TextMessage:
portal.HandleTextMessage(msg.source, data)
case whatsapp.ImageMessage:
portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.Type, data.Caption, false)
case whatsapp.StickerMessage:
portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.Type, "", true)
case whatsapp.VideoMessage:
portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.Type, data.Caption, false)
case whatsapp.AudioMessage:
portal.HandleMediaMessage(msg.source, data.Download, nil, data.Info, data.Type, "", false)
case whatsapp.DocumentMessage:
portal.HandleMediaMessage(msg.source, data.Download, data.Thumbnail, data.Info, data.Type, data.Title, false)
case whatsappExt.MessageRevocation:
portal.HandleMessageRevoke(msg.source, data)
case FakeMessage:
portal.HandleFakeMessage(msg.source, data)
default:
portal.log.Warnln("Unknown message type:", reflect.TypeOf(msg.data))
}
}
func (portal *Portal) isRecentlyHandled(id types.WhatsAppMessageID) bool {
start := portal.recentlyHandledIndex
for i := start; i != start; i = (i - 1) % recentlyHandledLength {
if portal.recentlyHandled[i] == id {
return true
}
}
return false
}
func (portal *Portal) isDuplicate(id types.WhatsAppMessageID) bool {
msg := portal.bridge.DB.Message.GetByJID(portal.Key, id)
if msg != nil {
return true
}
return false
}
func init() {
gob.Register(&waProto.Message{})
}
func (portal *Portal) markHandled(source *User, message *waProto.WebMessageInfo, mxid types.MatrixEventID) {
msg := portal.bridge.DB.Message.New()
msg.Chat = portal.Key
msg.JID = message.GetKey().GetId()
msg.MXID = mxid
msg.Timestamp = message.GetMessageTimestamp()
if message.GetKey().GetFromMe() {
msg.Sender = source.JID
} else if portal.IsPrivateChat() {
msg.Sender = portal.Key.JID
} else {
msg.Sender = message.GetKey().GetParticipant()
if len(msg.Sender) == 0 {
msg.Sender = message.GetParticipant()
}
}
msg.Content = message.Message
msg.Insert()
portal.recentlyHandledLock.Lock()
index := portal.recentlyHandledIndex
portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength
portal.recentlyHandledLock.Unlock()
portal.recentlyHandled[index] = msg.JID
}
func (portal *Portal) startHandling(info whatsapp.MessageInfo) bool {
if portal.lastMessageTs > info.Timestamp+1 ||
portal.isRecentlyHandled(info.Id) ||
portal.isDuplicate(info.Id) {
return false
}
portal.lastMessageTs = info.Timestamp
return true
}
func (portal *Portal) finishHandling(source *User, message *waProto.WebMessageInfo, mxid types.MatrixEventID) {
portal.markHandled(source, message, mxid)
portal.log.Debugln("Handled message", message.GetKey().GetId(), "->", mxid)
}
func (portal *Portal) SyncParticipants(metadata *whatsappExt.GroupInfo) {
changed := false
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
if err != nil {
levels = portal.GetBasePowerLevels()
changed = true
}
for _, participant := range metadata.Participants {
user := portal.bridge.GetUserByJID(participant.JID)
if user != nil && !portal.bridge.AS.StateStore.IsInvited(portal.MXID, user.MXID) {
_, err = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{
UserID: user.MXID,
})
if err != nil {
portal.log.Warnfln("Failed to invite %s to %s: %v", user.MXID, portal.MXID, err)
}
}
puppet := portal.bridge.GetPuppetByJID(participant.JID)
err := puppet.IntentFor(portal).EnsureJoined(portal.MXID)
if err != nil {
portal.log.Warnfln("Failed to make puppet of %s join %s: %v", participant.JID, portal.MXID, err)
}
expectedLevel := 0
if participant.IsSuperAdmin {
expectedLevel = 95
} else if participant.IsAdmin {
expectedLevel = 50
}
changed = levels.EnsureUserLevel(puppet.MXID, expectedLevel) || changed
if user != nil {
changed = levels.EnsureUserLevel(user.MXID, expectedLevel) || changed
}
}
if changed {
_, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels)
if err != nil {
portal.log.Errorln("Failed to change power levels:", err)
}
}
}
func (portal *Portal) UpdateAvatar(user *User, avatar *whatsappExt.ProfilePicInfo) bool {
if avatar == nil {
var err error
avatar, err = user.Conn.GetProfilePicThumb(portal.Key.JID)
if err != nil {
portal.log.Errorln(err)
return false
}
}
if avatar.Status != 0 {
return false
}
if portal.Avatar == avatar.Tag {
return false
}
data, err := avatar.DownloadBytes()
if err != nil {
portal.log.Warnln("Failed to download avatar:", err)
return false
}
mimeType := http.DetectContentType(data)
resp, err := portal.MainIntent().UploadBytes(data, mimeType)
if err != nil {
portal.log.Warnln("Failed to upload avatar:", err)
return false
}
portal.AvatarURL = resp.ContentURI
if len(portal.MXID) > 0 {
_, err = portal.MainIntent().SetRoomAvatar(portal.MXID, resp.ContentURI)
if err != nil {
portal.log.Warnln("Failed to set room topic:", err)
return false
}
}
portal.Avatar = avatar.Tag
return true
}
func (portal *Portal) UpdateName(name string, setBy types.WhatsAppID) bool {
if portal.Name != name {
intent := portal.MainIntent()
if len(setBy) > 0 {
intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal)
}
_, err := intent.SetRoomName(portal.MXID, name)
if err == nil {
portal.Name = name
return true
}
portal.log.Warnln("Failed to set room name:", err)
}
return false
}
func (portal *Portal) UpdateTopic(topic string, setBy types.WhatsAppID) bool {
if portal.Topic != topic {
intent := portal.MainIntent()
if len(setBy) > 0 {
intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal)
}
_, err := intent.SetRoomTopic(portal.MXID, topic)
if err == nil {
portal.Topic = topic
return true
}
portal.log.Warnln("Failed to set room topic:", err)
}
return false
}
func (portal *Portal) UpdateMetadata(user *User) bool {
if portal.IsPrivateChat() {
return false
} else if portal.IsStatusBroadcastRoom() {
update := false
update = portal.UpdateName("WhatsApp Status Broadcast", "") || update
update = portal.UpdateTopic("WhatsApp status updates from your contacts", "") || update
return update
}
metadata, err := user.Conn.GetGroupMetaData(portal.Key.JID)
if err != nil {
portal.log.Errorln(err)
return false
}
if metadata.Status != 0 {
// 401: access denied
// 404: group does (no longer) exist
// 500: ??? happens with status@broadcast
// TODO: update the room, e.g. change priority level
// to send messages to moderator
return false
}
portal.SyncParticipants(metadata)
update := false
update = portal.UpdateName(metadata.Name, metadata.NameSetBy) || update
update = portal.UpdateTopic(metadata.Topic, metadata.TopicSetBy) || update
return update
}
func (portal *Portal) ensureUserInvited(user *User) {
err := portal.MainIntent().EnsureInvited(portal.MXID, user.MXID)
if err != nil {
portal.log.Warnfln("Failed to ensure %s is invited to %s: %v", user.MXID, portal.MXID, err)
}
customPuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil {
_ = customPuppet.CustomIntent().EnsureJoined(portal.MXID)
}
}
func (portal *Portal) Sync(user *User, contact whatsapp.Contact) {
portal.log.Infoln("Syncing portal for", user.MXID)
if len(portal.MXID) == 0 {
if !portal.IsPrivateChat() {
portal.Name = contact.Name
}
err := portal.CreateMatrixRoom(user)
if err != nil {
portal.log.Errorln("Failed to create portal room:", err)
return
}
} else {
portal.ensureUserInvited(user)
}
if portal.IsPrivateChat() {
return
}
update := false
update = portal.UpdateMetadata(user) || update
if !portal.IsStatusBroadcastRoom() {
update = portal.UpdateAvatar(user, nil) || update
}
if update {
portal.Update()
}
}
func (portal *Portal) GetBasePowerLevels() *mautrix.PowerLevels {
anyone := 0
nope := 99
invite := 99
if portal.bridge.Config.Bridge.AllowUserInvite {
invite = 0
}
return &mautrix.PowerLevels{
UsersDefault: anyone,
EventsDefault: anyone,
RedactPtr: &anyone,
StateDefaultPtr: &nope,
BanPtr: &nope,
InvitePtr: &invite,
Users: map[string]int{
portal.MainIntent().UserID: 100,
},
Events: map[string]int{
mautrix.StateRoomName.Type: anyone,
mautrix.StateRoomAvatar.Type: anyone,
mautrix.StateTopic.Type: anyone,
},
}
}
func (portal *Portal) ChangeAdminStatus(jids []string, setAdmin bool) {
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
if err != nil {
levels = portal.GetBasePowerLevels()
}
newLevel := 0
if setAdmin {
newLevel = 50
}
changed := false
for _, jid := range jids {
puppet := portal.bridge.GetPuppetByJID(jid)
changed = levels.EnsureUserLevel(puppet.MXID, newLevel) || changed
user := portal.bridge.GetUserByJID(jid)
if user != nil {
changed = levels.EnsureUserLevel(user.MXID, newLevel) || changed
}
}
if changed {
_, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels)
if err != nil {
portal.log.Errorln("Failed to change power levels:", err)
}
}
}
func (portal *Portal) RestrictMessageSending(restrict bool) {
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
if err != nil {
levels = portal.GetBasePowerLevels()
}
if restrict {
levels.EventsDefault = 50
} else {
levels.EventsDefault = 0
}
_, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels)
if err != nil {
portal.log.Errorln("Failed to change power levels:", err)
}
}
func (portal *Portal) RestrictMetadataChanges(restrict bool) {
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
if err != nil {
levels = portal.GetBasePowerLevels()
}
newLevel := 0
if restrict {
newLevel = 50
}
changed := false
changed = levels.EnsureEventLevel(mautrix.StateRoomName, newLevel) || changed
changed = levels.EnsureEventLevel(mautrix.StateRoomAvatar, newLevel) || changed
changed = levels.EnsureEventLevel(mautrix.StateTopic, newLevel) || changed
if changed {
_, err = portal.MainIntent().SetPowerLevels(portal.MXID, levels)
if err != nil {
portal.log.Errorln("Failed to change power levels:", err)
}
}
}
func (portal *Portal) BackfillHistory(user *User, lastMessageTime uint64) error {
if !portal.bridge.Config.Bridge.RecoverHistory {
return nil
}
endBackfill := portal.beginBackfill()
defer endBackfill()
lastMessage := portal.bridge.DB.Message.GetLastInChat(portal.Key)
if lastMessage == nil {
return nil
}
if lastMessage.Timestamp >= lastMessageTime {
portal.log.Debugln("Not backfilling: no new messages")
return nil
}
lastMessageID := lastMessage.JID
lastMessageFromMe := lastMessage.Sender == user.JID
portal.log.Infoln("Backfilling history since", lastMessageID, "for", user.MXID)
for len(lastMessageID) > 0 {
portal.log.Debugln("Backfilling history: 50 messages after", lastMessageID)
resp, err := user.Conn.LoadMessagesAfter(portal.Key.JID, lastMessageID, lastMessageFromMe, 50)
if err != nil {
return err
}
messages, ok := resp.Content.([]interface{})
if !ok || len(messages) == 0 {
break
}
portal.handleHistory(user, messages)
lastMessageProto, ok := messages[len(messages)-1].(*waProto.WebMessageInfo)
if ok {
lastMessageID = lastMessageProto.GetKey().GetId()
lastMessageFromMe = lastMessageProto.GetKey().GetFromMe()
}
}
portal.log.Infoln("Backfilling finished")
return nil
}
func (portal *Portal) beginBackfill() func() {
portal.backfillLock.Lock()
portal.backfilling = true
var privateChatPuppetInvited bool
var privateChatPuppet *Puppet
if portal.IsPrivateChat() && portal.bridge.Config.Bridge.InviteOwnPuppetForBackfilling {
privateChatPuppet = portal.bridge.GetPuppetByJID(portal.Key.Receiver)
portal.privateChatBackfillInvitePuppet = func() {
if privateChatPuppetInvited {
return
}
privateChatPuppetInvited = true
_, _ = portal.MainIntent().InviteUser(portal.MXID, &mautrix.ReqInviteUser{UserID: privateChatPuppet.MXID})
_ = privateChatPuppet.DefaultIntent().EnsureJoined(portal.MXID)
}
}
return func() {
portal.backfilling = false
portal.privateChatBackfillInvitePuppet = nil
portal.backfillLock.Unlock()
if privateChatPuppet != nil && privateChatPuppetInvited {
_, _ = privateChatPuppet.DefaultIntent().LeaveRoom(portal.MXID)
}
}
}
func (portal *Portal) FillInitialHistory(user *User) error {
if portal.bridge.Config.Bridge.InitialHistoryFill == 0 {
return nil
}
endBackfill := portal.beginBackfill()
defer endBackfill()
if portal.privateChatBackfillInvitePuppet != nil {
portal.privateChatBackfillInvitePuppet()
}
n := portal.bridge.Config.Bridge.InitialHistoryFill
portal.log.Infoln("Filling initial history, maximum", n, "messages")
var messages []interface{}
before := ""
fromMe := true
chunkNum := 1
for n > 0 {
count := 50
if n < count {
count = n
}
portal.log.Debugfln("Fetching chunk %d (%d messages / %d cap) before message %s", chunkNum, count, n, before)
resp, err := user.Conn.LoadMessagesBefore(portal.Key.JID, before, fromMe, count)
if err != nil {
return err
}
chunk, ok := resp.Content.([]interface{})
if !ok || len(chunk) == 0 {
portal.log.Infoln("Chunk empty, starting handling of loaded messages")
break
}
messages = append(chunk, messages...)
portal.log.Debugfln("Fetched chunk and received %d messages", len(chunk))
n -= len(chunk)
key := chunk[0].(*waProto.WebMessageInfo).GetKey()
before = key.GetId()
fromMe = key.GetFromMe()
if len(before) == 0 {
portal.log.Infoln("No message ID for first message, starting handling of loaded messages")
break
}
}
portal.handleHistory(user, messages)
portal.log.Infoln("Initial history fill complete")
return nil
}
func (portal *Portal) handleHistory(user *User, messages []interface{}) {
portal.log.Infoln("Handling", len(messages), "messages of history")
for _, rawMessage := range messages {
message, ok := rawMessage.(*waProto.WebMessageInfo)
if !ok {
portal.log.Warnln("Unexpected non-WebMessageInfo item in history response:", rawMessage)
continue
}
data := whatsapp.ParseProtoMessage(message)
if data == nil {
portal.log.Warnln("Message", message.GetKey().GetId(), "failed to parse during backfilling")
continue
}
if portal.privateChatBackfillInvitePuppet != nil && message.GetKey().GetFromMe() && portal.IsPrivateChat() {
portal.privateChatBackfillInvitePuppet()
}
portal.handleMessage(PortalMessage{portal.Key.JID, user, data, message.GetMessageTimestamp()})
}
}
func (portal *Portal) CreateMatrixRoom(user *User) error {
portal.roomCreateLock.Lock()
defer portal.roomCreateLock.Unlock()
if len(portal.MXID) > 0 {
return nil
}
intent := portal.MainIntent()
if err := intent.EnsureRegistered(); err != nil {
return err
}
portal.log.Infoln("Creating Matrix room. Info source:", user.MXID)
var metadata *whatsappExt.GroupInfo
isPrivateChat := false
if portal.IsPrivateChat() {
puppet := portal.bridge.GetPuppetByJID(portal.Key.JID)
if portal.bridge.Config.Bridge.PrivateChatPortalMeta {
portal.Name = puppet.Displayname
portal.AvatarURL = puppet.AvatarURL
portal.Avatar = puppet.Avatar
} else {
portal.Name = ""
}
portal.Topic = "WhatsApp private chat"
isPrivateChat = true
} else if portal.IsStatusBroadcastRoom() {
portal.Name = "WhatsApp Status Broadcast"
portal.Topic = "WhatsApp status updates from your contacts"
} else {
var err error
metadata, err = user.Conn.GetGroupMetaData(portal.Key.JID)
if err == nil && metadata.Status == 0 {
portal.Name = metadata.Name
portal.Topic = metadata.Topic
}
portal.UpdateAvatar(user, nil)
}
initialState := []*mautrix.Event{{
Type: mautrix.StatePowerLevels,
Content: mautrix.Content{
PowerLevels: portal.GetBasePowerLevels(),
},
}}
if len(portal.AvatarURL) > 0 {
initialState = append(initialState, &mautrix.Event{
Type: mautrix.StateRoomAvatar,
Content: mautrix.Content{
URL: portal.AvatarURL,
},
})
}
resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{
Visibility: "private",
Name: portal.Name,
Topic: portal.Topic,
Invite: []string{user.MXID},
Preset: "private_chat",
IsDirect: isPrivateChat,
InitialState: initialState,
})
if err != nil {
return err
}
portal.MXID = resp.RoomID
portal.Update()
if metadata != nil {
portal.SyncParticipants(metadata)
} else {
customPuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID)
if customPuppet != nil && customPuppet.CustomIntent() != nil {
_ = customPuppet.CustomIntent().EnsureJoined(portal.MXID)
}
}
user.addPortalToCommunity(portal)
err = portal.FillInitialHistory(user)
if err != nil {
portal.log.Errorln("Failed to fill history:", err)
}
return nil
}
func (portal *Portal) IsPrivateChat() bool {
if portal.isPrivate == nil {
val := strings.HasSuffix(portal.Key.JID, whatsappExt.NewUserSuffix)
portal.isPrivate = &val
}
return *portal.isPrivate
}
func (portal *Portal) IsStatusBroadcastRoom() bool {
return portal.Key.JID == "status@broadcast"
}
func (portal *Portal) MainIntent() *appservice.IntentAPI {
if portal.IsPrivateChat() {
return portal.bridge.GetPuppetByJID(portal.Key.JID).DefaultIntent()
}
return portal.bridge.Bot
}
func (portal *Portal) GetMessageIntent(user *User, info whatsapp.MessageInfo) *appservice.IntentAPI {
if info.FromMe {
return portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal)
} else if portal.IsPrivateChat() {
return portal.MainIntent()
} else if len(info.SenderJid) == 0 {
if len(info.Source.GetParticipant()) != 0 {
info.SenderJid = info.Source.GetParticipant()
} else {
return nil
}
}
return portal.bridge.GetPuppetByJID(info.SenderJid).IntentFor(portal)
}
func (portal *Portal) SetReply(content *mautrix.Content, info whatsapp.MessageInfo) {
if len(info.QuotedMessageID) == 0 {
return
}
message := portal.bridge.DB.Message.GetByJID(portal.Key, info.QuotedMessageID)
if message != nil {
event, err := portal.MainIntent().GetEvent(portal.MXID, message.MXID)
if err != nil {
portal.log.Warnln("Failed to get reply target:", err)
return
}
event.Content.RemoveReplyFallback()
content.SetReply(event)
}
return
}
func (portal *Portal) HandleMessageRevoke(user *User, message whatsappExt.MessageRevocation) {
msg := portal.bridge.DB.Message.GetByJID(portal.Key, message.Id)
if msg == nil {
return
}
var intent *appservice.IntentAPI
if message.FromMe {
if portal.IsPrivateChat() {
intent = portal.bridge.GetPuppetByJID(user.JID).CustomIntent()
} else {
intent = portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal)
}
} else if len(message.Participant) > 0 {
intent = portal.bridge.GetPuppetByJID(message.Participant).IntentFor(portal)
}
if intent == nil {
intent = portal.MainIntent()
}
_, err := intent.RedactEvent(portal.MXID, msg.MXID)
if err != nil {
portal.log.Errorln("Failed to redact %s: %v", msg.JID, err)
return
}
msg.Delete()
}
func (portal *Portal) HandleFakeMessage(source *User, message FakeMessage) {
if portal.isRecentlyHandled(message.ID) {
return
}
_, err := portal.MainIntent().SendNotice(portal.MXID, message.Text)
if err != nil {
portal.log.Errorfln("Failed to handle fake message %s: %v", message.ID, err)
return
}
portal.recentlyHandledLock.Lock()
index := portal.recentlyHandledIndex
portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength
portal.recentlyHandledLock.Unlock()
portal.recentlyHandled[index] = message.ID
}
type MessageContent struct {
*mautrix.Content
IsCustomPuppet bool `json:"net.maunium.whatsapp.puppet,omitempty"`
}
func (portal *Portal) HandleTextMessage(source *User, message whatsapp.TextMessage) {
if !portal.startHandling(message.Info) {
return
}
intent := portal.GetMessageIntent(source, message.Info)
if intent == nil {
return
}
content := &mautrix.Content{
Body: message.Text,
MsgType: mautrix.MsgText,
}
portal.bridge.Formatter.ParseWhatsApp(content)
portal.SetReply(content, message.Info)
_, _ = intent.UserTyping(portal.MXID, false, 0)
resp, err := intent.SendMassagedMessageEvent(portal.MXID, mautrix.EventMessage, &MessageContent{content, intent.IsCustomPuppet}, int64(message.Info.Timestamp*1000))
if err != nil {
portal.log.Errorfln("Failed to handle message %s: %v", message.Info.Id, err)
return
}
portal.finishHandling(source, message.Info.Source, resp.EventID)
}
func (portal *Portal) HandleMediaMessage(source *User, download func() ([]byte, error), thumbnail []byte, info whatsapp.MessageInfo, mimeType, caption string, sendAsSticker bool) {
if !portal.startHandling(info) {
return
}
intent := portal.GetMessageIntent(source, info)
if intent == nil {
return
}
data, err := download()
if err != nil {
portal.log.Errorfln("Failed to download media for %s: %v", info.Id, err)
resp, err := portal.MainIntent().SendNotice(portal.MXID, "Failed to bridge media")
if err != nil {
portal.log.Errorfln("Failed to send media download error message for %s: %v", info.Id, err)
} else {
portal.finishHandling(source, info.Source, resp.EventID)
}
return
}
// WhatsApp sends incorrect mime types 3:<
if detected := http.DetectContentType(data); detected != "application/octet-stream" {
mimeType = detected
}
// synapse doesn't handle webp well, so we convert it. This can be dropped once https://github.com/matrix-org/synapse/issues/4382 is fixed
if mimeType == "image/webp" {
img, err := webp.Decode(bytes.NewReader(data))
if err != nil {
portal.log.Errorfln("Failed to decode media for %s: %v", err)
return
}
var buf bytes.Buffer
err = png.Encode(&buf, img)
if err != nil {
portal.log.Errorfln("Failed to convert media for %s: %v", err)
return
}
data = buf.Bytes()
mimeType = "image/png"
}
uploaded, err := intent.UploadBytes(data, mimeType)
if err != nil {
portal.log.Errorfln("Failed to upload media for %s: %v", err)
return
}
fileName := info.Id
exts, _ := mime.ExtensionsByType(mimeType)
if exts != nil && len(exts) > 0 {
fileName += exts[0]
}
content := &mautrix.Content{
Body: fileName,
URL: uploaded.ContentURI,
Info: &mautrix.FileInfo{
Size: len(data),
MimeType: mimeType,
},
}
portal.SetReply(content, info)
if thumbnail != nil {
thumbnailMime := http.DetectContentType(thumbnail)
uploadedThumbnail, _ := intent.UploadBytes(thumbnail, thumbnailMime)
if uploadedThumbnail != nil {
content.Info.ThumbnailURL = uploadedThumbnail.ContentURI
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
content.Info.ThumbnailInfo = &mautrix.FileInfo{
Size: len(thumbnail),
Width: cfg.Width,
Height: cfg.Height,
MimeType: thumbnailMime,
}
}
}
switch strings.ToLower(strings.Split(mimeType, "/")[0]) {
case "image":
if (!sendAsSticker) {
content.MsgType = mautrix.MsgImage
}
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
content.Info.Width = cfg.Width
content.Info.Height = cfg.Height
case "video":
content.MsgType = mautrix.MsgVideo
case "audio":
content.MsgType = mautrix.MsgAudio
default:
content.MsgType = mautrix.MsgFile
}
_, _ = intent.UserTyping(portal.MXID, false, 0)
ts := int64(info.Timestamp * 1000)
eventType := mautrix.EventMessage
if sendAsSticker {
eventType = mautrix.EventSticker
}
resp, err := intent.SendMassagedMessageEvent(portal.MXID, eventType, &MessageContent{content, intent.IsCustomPuppet}, ts)
if err != nil {
portal.log.Errorfln("Failed to handle message %s: %v", info.Id, err)
return
}
if len(caption) > 0 {
captionContent := &mautrix.Content{
Body: caption,
MsgType: mautrix.MsgNotice,
}
portal.bridge.Formatter.ParseWhatsApp(captionContent)
_, err := intent.SendMassagedMessageEvent(portal.MXID, mautrix.EventMessage, &MessageContent{captionContent, intent.IsCustomPuppet}, ts)
if err != nil {
portal.log.Warnfln("Failed to handle caption of message %s: %v", info.Id, err)
}
// TODO store caption mxid?
}
portal.finishHandling(source, info.Source, resp.EventID)
}
func makeMessageID() *string {
b := make([]byte, 10)
rand.Read(b)
str := strings.ToUpper(hex.EncodeToString(b))
return &str
}
func (portal *Portal) downloadThumbnail(evt *mautrix.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(sender *User, evt *mautrix.Event, mediaType whatsapp.MediaType) *MediaUpload {
if evt.Content.Info == nil {
evt.Content.Info = &mautrix.FileInfo{}
}
caption := evt.Content.Body
exts, err := mime.ExtensionsByType(evt.Content.Info.MimeType)
for _, ext := range exts {
if strings.HasSuffix(caption, ext) {
caption = ""
break
}
}
content, err := portal.MainIntent().DownloadBytes(evt.Content.URL)
if err != nil {
portal.log.Errorfln("Failed to download media in %s: %v", evt.ID, err)
return nil
}
url, mediaKey, fileEncSHA256, fileSHA256, fileLength, err := sender.Conn.Upload(bytes.NewReader(content), mediaType)
if err != nil {
portal.log.Errorfln("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) sendMatrixConnectionError(sender *User, eventID string) bool {
if !sender.HasSession() {
portal.log.Debugln("Ignoring event", eventID, "from", sender.MXID, "as user has no session")
return true
} else if !sender.IsConnected() {
portal.log.Debugln("Ignoring event", eventID, "from", sender.MXID, "as user is not connected")
inRoom := ""
if portal.IsPrivateChat() {
inRoom = " in your management room"
}
reconnect := fmt.Sprintf("Use `%s reconnect`%s to reconnect.", portal.bridge.Config.Bridge.CommandPrefix, inRoom)
if sender.IsLoginInProgress() {
reconnect = "You have a login attempt in progress, please wait."
}
msg := format.RenderMarkdown("\u26a0 You are not connected to WhatsApp, so your message was not bridged. " + reconnect)
msg.MsgType = mautrix.MsgNotice
_, err := portal.MainIntent().SendMessageEvent(portal.MXID, mautrix.EventMessage, msg)
if err != nil {
portal.log.Errorln("Failed to send bridging failure message:", err)
}
return true
}
return false
}
func (portal *Portal) HandleMatrixMessage(sender *User, evt *mautrix.Event) {
if portal.IsPrivateChat() && sender.JID != portal.Key.Receiver {
return
} else if portal.sendMatrixConnectionError(sender, evt.ID) {
return
}
portal.log.Debugfln("Received event %s", evt.ID)
ts := uint64(evt.Timestamp / 1000)
status := waProto.WebMessageInfo_ERROR
fromMe := true
info := &waProto.WebMessageInfo{
Key: &waProto.MessageKey{
FromMe: &fromMe,
Id: makeMessageID(),
RemoteJid: &portal.Key.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 && msg.Content != nil {
ctxInfo.StanzaId = &msg.JID
ctxInfo.Participant = &msg.Sender
ctxInfo.QuotedMessage = msg.Content
}
}
var err error
switch evt.Content.MsgType {
case mautrix.MsgText, mautrix.MsgEmote:
text := evt.Content.Body
if evt.Content.Format == mautrix.FormatHTML {
text = portal.bridge.Formatter.ParseMatrix(evt.Content.FormattedBody)
}
if evt.Content.MsgType == mautrix.MsgEmote {
text = "/me " + text
}
ctxInfo.MentionedJid = mentionRegex.FindAllString(text, -1)
for index, mention := range ctxInfo.MentionedJid {
ctxInfo.MentionedJid[index] = mention[1:] + whatsappExt.NewUserSuffix
}
if ctxInfo.StanzaId != nil || ctxInfo.MentionedJid != nil {
info.Message.ExtendedTextMessage = &waProto.ExtendedTextMessage{
Text: &text,
ContextInfo: ctxInfo,
}
} else {
info.Message.Conversation = &text
}
case mautrix.MsgImage:
media := portal.preprocessMatrixMedia(sender, evt, whatsapp.MediaImage)
if media == nil {
return
}
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 mautrix.MsgVideo:
media := portal.preprocessMatrixMedia(sender, evt, whatsapp.MediaVideo)
if media == nil {
return
}
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 mautrix.MsgAudio:
media := portal.preprocessMatrixMedia(sender, evt, whatsapp.MediaAudio)
if media == nil {
return
}
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 mautrix.MsgFile:
media := portal.preprocessMatrixMedia(sender, evt, whatsapp.MediaDocument)
if media == nil {
return
}
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(sender, info, evt.ID)
portal.log.Debugln("Sending event", evt.ID, "to WhatsApp")
_, err = sender.Conn.Send(info)
if err != nil {
portal.log.Errorfln("Error handling Matrix event %s: %v", evt.ID, err)
msg := format.RenderMarkdown(fmt.Sprintf("\u26a0 Your message may not have been bridged: %v", err))
msg.MsgType = mautrix.MsgNotice
_, err := portal.MainIntent().SendMessageEvent(portal.MXID, mautrix.EventMessage, msg)
if err != nil {
portal.log.Errorln("Failed to send bridging failure message:", err)
}
} else {
portal.log.Debugln("Handled Matrix event:", evt)
}
}
func (portal *Portal) HandleMatrixRedaction(sender *User, evt *mautrix.Event) {
if portal.IsPrivateChat() && sender.JID != portal.Key.Receiver {
return
}
msg := portal.bridge.DB.Message.GetByMXID(evt.Redacts)
if msg == nil || msg.Sender != sender.JID {
return
}
ts := uint64(evt.Timestamp / 1000)
status := waProto.WebMessageInfo_PENDING
protoMsgType := waProto.ProtocolMessage_REVOKE
fromMe := true
info := &waProto.WebMessageInfo{
Key: &waProto.MessageKey{
FromMe: &fromMe,
Id: makeMessageID(),
RemoteJid: &portal.Key.JID,
},
MessageTimestamp: &ts,
Message: &waProto.Message{
ProtocolMessage: &waProto.ProtocolMessage{
Type: &protoMsgType,
Key: &waProto.MessageKey{
FromMe: &fromMe,
Id: &msg.JID,
RemoteJid: &portal.Key.JID,
},
},
},
Status: &status,
}
_, err := sender.Conn.Send(info)
if err != nil {
portal.log.Errorfln("Error handling Matrix redaction: %s: %v", evt.ID, err)
} else {
portal.log.Debugln("Handled Matrix redaction:", evt)
}
}
func (portal *Portal) Delete() {
portal.Portal.Delete()
delete(portal.bridge.portalsByJID, portal.Key)
if len(portal.MXID) > 0 {
delete(portal.bridge.portalsByMXID, portal.MXID)
}
}
func (portal *Portal) Cleanup(puppetsOnly bool) {
if len(portal.MXID) == 0 {
return
}
if portal.IsPrivateChat() {
_, err := portal.MainIntent().LeaveRoom(portal.MXID)
if err != nil {
portal.log.Warnln("Failed to leave private chat portal with main intent:", err)
}
return
}
intent := portal.MainIntent()
members, err := intent.JoinedMembers(portal.MXID)
if err != nil {
portal.log.Errorln("Failed to get portal members for cleanup:", err)
return
}
for member, _ := range members.Joined {
if member == intent.UserID {
continue
}
puppet := portal.bridge.GetPuppetByMXID(member)
if puppet != nil {
_, err = puppet.DefaultIntent().LeaveRoom(portal.MXID)
if err != nil {
portal.log.Errorln("Error leaving as puppet while cleaning up portal:", err)
}
} else if !puppetsOnly {
_, err = intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"})
if err != nil {
portal.log.Errorln("Error kicking user while cleaning up portal:", err)
}
}
}
_, err = intent.LeaveRoom(portal.MXID)
if err != nil {
portal.log.Errorln("Error leaving with main intent while cleaning up portal:", err)
}
}
func (portal *Portal) HandleMatrixLeave(sender *User) {
if portal.IsPrivateChat() {
portal.log.Debugln("User left private chat portal, cleaning up and deleting...")
portal.Delete()
portal.Cleanup(false)
return
}
}
func (portal *Portal) HandleMatrixKick(sender *User, event *mautrix.Event) {
// TODO
}