forked from MirrorHub/mautrix-whatsapp
b874d324eb
This is necessary in case there aren't any real messages before the next backfill. Otherwise the next backfill would go before the old backfill (at the top of the room) rather than at the bottom of the room.
2555 lines
80 KiB
Go
2555 lines
80 KiB
Go
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
// Copyright (C) 2021 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"
|
|
"context"
|
|
"encoding/gob"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"image"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io/ioutil"
|
|
"math"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/image/webp"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
"go.mau.fi/whatsmeow"
|
|
waProto "go.mau.fi/whatsmeow/binary/proto"
|
|
"go.mau.fi/whatsmeow/types"
|
|
"go.mau.fi/whatsmeow/types/events"
|
|
|
|
log "maunium.net/go/maulogger/v2"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/appservice"
|
|
"maunium.net/go/mautrix/crypto/attachment"
|
|
"maunium.net/go/mautrix/event"
|
|
"maunium.net/go/mautrix/id"
|
|
|
|
"maunium.net/go/mautrix-whatsapp/database"
|
|
)
|
|
|
|
const StatusBroadcastTopic = "WhatsApp status updates from your contacts"
|
|
const StatusBroadcastName = "WhatsApp Status Broadcast"
|
|
const BroadcastTopic = "WhatsApp broadcast list"
|
|
const UnnamedBroadcastName = "Unnamed broadcast list"
|
|
const PrivateChatTopic = "WhatsApp private chat"
|
|
|
|
var ErrStatusBroadcastDisabled = errors.New("status bridging is disabled")
|
|
|
|
func (bridge *Bridge) GetPortalByMXID(mxid id.RoomID) *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.JID) []*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) NewManualPortal(key database.PortalKey) *Portal {
|
|
portal := &Portal{
|
|
Portal: bridge.DB.Portal.New(),
|
|
bridge: bridge,
|
|
log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", key)),
|
|
|
|
messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer),
|
|
}
|
|
portal.Key = key
|
|
go portal.handleMessageLoop()
|
|
return portal
|
|
}
|
|
|
|
func (bridge *Bridge) NewPortal(dbPortal *database.Portal) *Portal {
|
|
portal := &Portal{
|
|
Portal: dbPortal,
|
|
bridge: bridge,
|
|
log: bridge.Log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)),
|
|
|
|
messages: make(chan PortalMessage, bridge.Config.Bridge.PortalMessageBuffer),
|
|
}
|
|
go portal.handleMessageLoop()
|
|
return portal
|
|
}
|
|
|
|
const recentlyHandledLength = 100
|
|
|
|
type PortalMessage struct {
|
|
evt *events.Message
|
|
source *User
|
|
}
|
|
|
|
type Portal struct {
|
|
*database.Portal
|
|
|
|
bridge *Bridge
|
|
log log.Logger
|
|
|
|
roomCreateLock sync.Mutex
|
|
encryptLock sync.Mutex
|
|
backfillLock sync.Mutex
|
|
|
|
recentlyHandled [recentlyHandledLength]types.MessageID
|
|
recentlyHandledLock sync.Mutex
|
|
recentlyHandledIndex uint8
|
|
|
|
privateChatBackfillInvitePuppet func()
|
|
|
|
messages chan PortalMessage
|
|
|
|
hasRelaybot *bool
|
|
}
|
|
|
|
const MaxMessageAgeToCreatePortal = 5 * 60 // 5 minutes
|
|
|
|
func (portal *Portal) syncDoublePuppetDetailsAfterCreate(source *User) {
|
|
doublePuppet := portal.bridge.GetPuppetByCustomMXID(source.MXID)
|
|
if doublePuppet == nil {
|
|
return
|
|
}
|
|
source.syncChatDoublePuppetDetails(doublePuppet, portal, true)
|
|
}
|
|
|
|
func (portal *Portal) handleMessageLoop() {
|
|
for msg := range portal.messages {
|
|
if len(portal.MXID) == 0 {
|
|
if !portal.shouldCreateRoom(msg) {
|
|
portal.log.Debugln("Not creating portal room for incoming message: message is not a chat message")
|
|
continue
|
|
}
|
|
portal.log.Debugln("Creating Matrix room from incoming message")
|
|
err := portal.CreateMatrixRoom(msg.source)
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to create portal room:", err)
|
|
continue
|
|
}
|
|
portal.syncDoublePuppetDetailsAfterCreate(msg.source)
|
|
}
|
|
//portal.backfillLock.Lock()
|
|
portal.handleMessage(msg.source, msg.evt)
|
|
//portal.backfillLock.Unlock()
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) shouldCreateRoom(msg PortalMessage) bool {
|
|
waMsg := msg.evt.Message
|
|
supportedMessages := []interface{}{
|
|
waMsg.Conversation,
|
|
waMsg.ExtendedTextMessage,
|
|
waMsg.ImageMessage,
|
|
waMsg.StickerMessage,
|
|
waMsg.VideoMessage,
|
|
waMsg.AudioMessage,
|
|
waMsg.VideoMessage,
|
|
waMsg.DocumentMessage,
|
|
waMsg.ContactMessage,
|
|
waMsg.LocationMessage,
|
|
}
|
|
for _, message := range supportedMessages {
|
|
if message != nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (portal *Portal) getMessageType(waMsg *waProto.Message) string {
|
|
switch {
|
|
case waMsg == nil:
|
|
return "ignore"
|
|
case waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil:
|
|
return "text"
|
|
case waMsg.ImageMessage != nil:
|
|
return fmt.Sprintf("image %s", waMsg.GetImageMessage().GetMimetype())
|
|
case waMsg.StickerMessage != nil:
|
|
return fmt.Sprintf("sticker %s", waMsg.GetStickerMessage().GetMimetype())
|
|
case waMsg.VideoMessage != nil:
|
|
return fmt.Sprintf("video %s", waMsg.GetVideoMessage().GetMimetype())
|
|
case waMsg.AudioMessage != nil:
|
|
return fmt.Sprintf("audio %s", waMsg.GetAudioMessage().GetMimetype())
|
|
case waMsg.DocumentMessage != nil:
|
|
return fmt.Sprintf("document %s", waMsg.GetDocumentMessage().GetMimetype())
|
|
case waMsg.ContactMessage != nil:
|
|
return "contact"
|
|
case waMsg.LocationMessage != nil:
|
|
return "location"
|
|
case waMsg.GetProtocolMessage() != nil:
|
|
switch waMsg.GetProtocolMessage().GetType() {
|
|
case waProto.ProtocolMessage_REVOKE:
|
|
return "revoke"
|
|
case waProto.ProtocolMessage_APP_STATE_SYNC_KEY_SHARE, waProto.ProtocolMessage_HISTORY_SYNC_NOTIFICATION, waProto.ProtocolMessage_INITIAL_SECURITY_NOTIFICATION_SETTING_SYNC:
|
|
return "ignore"
|
|
default:
|
|
return "unknown_protocol"
|
|
}
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) convertMessage(intent *appservice.IntentAPI, source *User, info *types.MessageInfo, waMsg *waProto.Message) *ConvertedMessage {
|
|
switch {
|
|
case waMsg.Conversation != nil || waMsg.ExtendedTextMessage != nil:
|
|
return portal.convertTextMessage(intent, waMsg)
|
|
case waMsg.ImageMessage != nil:
|
|
return portal.convertMediaMessage(intent, source, info, waMsg.GetImageMessage())
|
|
case waMsg.StickerMessage != nil:
|
|
return portal.convertMediaMessage(intent, source, info, waMsg.GetStickerMessage())
|
|
case waMsg.VideoMessage != nil:
|
|
return portal.convertMediaMessage(intent, source, info, waMsg.GetVideoMessage())
|
|
case waMsg.AudioMessage != nil:
|
|
return portal.convertMediaMessage(intent, source, info, waMsg.GetAudioMessage())
|
|
case waMsg.DocumentMessage != nil:
|
|
return portal.convertMediaMessage(intent, source, info, waMsg.GetDocumentMessage())
|
|
case waMsg.ContactMessage != nil:
|
|
return portal.convertContactMessage(intent, waMsg.GetContactMessage())
|
|
case waMsg.LocationMessage != nil:
|
|
return portal.convertLocationMessage(intent, waMsg.GetLocationMessage())
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) handleMessage(source *User, evt *events.Message) {
|
|
if len(portal.MXID) == 0 {
|
|
portal.log.Warnln("handleMessage called even though portal.MXID is empty")
|
|
return
|
|
}
|
|
msgID := evt.Info.ID
|
|
msgType := portal.getMessageType(evt.Message)
|
|
if msgType == "ignore" {
|
|
return
|
|
} else if portal.isRecentlyHandled(msgID) {
|
|
portal.log.Debugfln("Not handling %s (%s): message was recently handled", msgID, msgType)
|
|
return
|
|
} else if portal.isDuplicate(msgID) {
|
|
portal.log.Debugfln("Not handling %s (%s): message is duplicate", msgID, msgType)
|
|
return
|
|
}
|
|
intent := portal.getMessageIntent(source, &evt.Info)
|
|
converted := portal.convertMessage(intent, source, &evt.Info, evt.Message)
|
|
if converted != nil {
|
|
var eventID id.EventID
|
|
resp, err := portal.sendMessage(converted.Intent, converted.Type, converted.Content, evt.Info.Timestamp.UnixMilli())
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to send %s to Matrix: %v", msgID, err)
|
|
} else {
|
|
eventID = resp.EventID
|
|
}
|
|
if converted.Caption != nil {
|
|
resp, err = portal.sendMessage(converted.Intent, converted.Type, converted.Content, evt.Info.Timestamp.UnixMilli())
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to send caption of %s to Matrix: %v", msgID, err)
|
|
} else {
|
|
eventID = resp.EventID
|
|
}
|
|
}
|
|
if len(eventID) != 0 {
|
|
portal.finishHandling(&evt.Info, resp.EventID)
|
|
}
|
|
} else if msgType == "revoke" {
|
|
portal.HandleMessageRevoke(source, evt.Message.GetProtocolMessage().GetKey())
|
|
} else {
|
|
portal.log.Warnln("Unhandled message:", evt.Info, evt.Message)
|
|
return
|
|
}
|
|
portal.bridge.Metrics.TrackWhatsAppMessage(evt.Info.Timestamp, strings.Split(msgType, " ")[0])
|
|
}
|
|
|
|
func (portal *Portal) isRecentlyHandled(id types.MessageID) 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.MessageID) 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(info *types.MessageInfo, mxid id.EventID, isSent, recent bool) *database.Message {
|
|
msg := portal.bridge.DB.Message.New()
|
|
msg.Chat = portal.Key
|
|
msg.JID = info.ID
|
|
msg.MXID = mxid
|
|
msg.Timestamp = info.Timestamp
|
|
msg.Sender = info.Sender
|
|
msg.Sent = isSent
|
|
msg.Insert()
|
|
|
|
if recent {
|
|
portal.recentlyHandledLock.Lock()
|
|
index := portal.recentlyHandledIndex
|
|
portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength
|
|
portal.recentlyHandledLock.Unlock()
|
|
portal.recentlyHandled[index] = msg.JID
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func (portal *Portal) getMessagePuppet(user *User, info *types.MessageInfo) *Puppet {
|
|
if info.IsFromMe {
|
|
return portal.bridge.GetPuppetByJID(user.JID)
|
|
} else if portal.IsPrivateChat() {
|
|
return portal.bridge.GetPuppetByJID(portal.Key.JID)
|
|
} else {
|
|
puppet := portal.bridge.GetPuppetByJID(info.Sender)
|
|
puppet.SyncContact(user, true)
|
|
return puppet
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) getMessageIntent(user *User, info *types.MessageInfo) *appservice.IntentAPI {
|
|
if info.IsFromMe {
|
|
return portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal)
|
|
} else if portal.IsPrivateChat() {
|
|
return portal.MainIntent()
|
|
}
|
|
puppet := portal.bridge.GetPuppetByJID(info.Sender)
|
|
puppet.SyncContact(user, true)
|
|
return puppet.IntentFor(portal)
|
|
}
|
|
|
|
func (portal *Portal) finishHandling(message *types.MessageInfo, mxid id.EventID) {
|
|
portal.markHandled(message, mxid, true, true)
|
|
portal.sendDeliveryReceipt(mxid)
|
|
portal.log.Debugln("Handled message", message.ID, "->", mxid)
|
|
}
|
|
|
|
func (portal *Portal) kickExtraUsers(participantMap map[types.JID]bool) {
|
|
members, err := portal.MainIntent().JoinedMembers(portal.MXID)
|
|
if err != nil {
|
|
portal.log.Warnln("Failed to get member list:", err)
|
|
return
|
|
}
|
|
for member := range members.Joined {
|
|
jid, ok := portal.bridge.ParsePuppetMXID(member)
|
|
if ok {
|
|
_, shouldBePresent := participantMap[jid]
|
|
if !shouldBePresent {
|
|
_, err = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{
|
|
UserID: member,
|
|
Reason: "User had left this WhatsApp chat",
|
|
})
|
|
if err != nil {
|
|
portal.log.Warnfln("Failed to kick user %s who had left: %v", member, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//func (portal *Portal) SyncBroadcastRecipients(source *User, metadata *whatsapp.BroadcastListInfo) {
|
|
// participantMap := make(map[whatsapp.JID]bool)
|
|
// for _, recipient := range metadata.Recipients {
|
|
// participantMap[recipient.JID] = true
|
|
//
|
|
// puppet := portal.bridge.GetPuppetByJID(recipient.JID)
|
|
// puppet.SyncContactIfNecessary(source)
|
|
// err := puppet.DefaultIntent().EnsureJoined(portal.MXID)
|
|
// if err != nil {
|
|
// portal.log.Warnfln("Failed to make puppet of %s join %s: %v", recipient.JID, portal.MXID, err)
|
|
// }
|
|
// }
|
|
// portal.kickExtraUsers(participantMap)
|
|
//}
|
|
|
|
func (portal *Portal) SyncParticipants(source *User, metadata *types.GroupInfo) {
|
|
changed := false
|
|
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
|
|
if err != nil {
|
|
levels = portal.GetBasePowerLevels()
|
|
changed = true
|
|
}
|
|
participantMap := make(map[types.JID]bool)
|
|
for _, participant := range metadata.Participants {
|
|
participantMap[participant.JID] = true
|
|
user := portal.bridge.GetUserByJID(participant.JID)
|
|
portal.userMXIDAction(user, portal.ensureMXIDInvited)
|
|
|
|
puppet := portal.bridge.GetPuppetByJID(participant.JID)
|
|
puppet.SyncContact(source, true)
|
|
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.JID == metadata.OwnerJID {
|
|
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)
|
|
}
|
|
}
|
|
portal.kickExtraUsers(participantMap)
|
|
}
|
|
|
|
func (portal *Portal) UpdateAvatar(user *User, updateInfo bool) bool {
|
|
avatar, err := user.Client.GetProfilePictureInfo(portal.Key.JID, false)
|
|
if err != nil {
|
|
if !errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) {
|
|
portal.log.Warnln("Failed to get avatar URL:", err)
|
|
}
|
|
return false
|
|
} else if avatar == nil {
|
|
if portal.Avatar == "remove" {
|
|
return false
|
|
}
|
|
portal.AvatarURL = id.ContentURI{}
|
|
avatar = &types.ProfilePictureInfo{ID: "remove"}
|
|
} else if avatar.ID == portal.Avatar {
|
|
return false
|
|
} else if len(avatar.URL) == 0 {
|
|
portal.log.Warnln("Didn't get URL in response to avatar query")
|
|
return false
|
|
} else {
|
|
url, err := reuploadAvatar(portal.MainIntent(), avatar.URL)
|
|
if err != nil {
|
|
portal.log.Warnln("Failed to reupload avatar:", err)
|
|
return false
|
|
}
|
|
portal.AvatarURL = url
|
|
}
|
|
|
|
if len(portal.MXID) > 0 {
|
|
_, err := portal.MainIntent().SetRoomAvatar(portal.MXID, portal.AvatarURL)
|
|
if err != nil {
|
|
portal.log.Warnln("Failed to set room topic:", err)
|
|
return false
|
|
}
|
|
}
|
|
portal.Avatar = avatar.ID
|
|
if updateInfo {
|
|
portal.UpdateBridgeInfo()
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (portal *Portal) UpdateName(name string, setBy types.JID, intent *appservice.IntentAPI, updateInfo bool) bool {
|
|
if name == "" && portal.IsBroadcastList() {
|
|
name = UnnamedBroadcastName
|
|
}
|
|
if portal.Name != name {
|
|
portal.log.Debugfln("Updating name %s -> %s", portal.Name, name)
|
|
portal.Name = name
|
|
if intent == nil {
|
|
intent = portal.MainIntent()
|
|
if !setBy.IsEmpty() {
|
|
intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal)
|
|
}
|
|
}
|
|
_, err := intent.SetRoomName(portal.MXID, name)
|
|
if err == nil {
|
|
if updateInfo {
|
|
portal.UpdateBridgeInfo()
|
|
}
|
|
return true
|
|
} else {
|
|
portal.Name = ""
|
|
portal.log.Warnln("Failed to set room name:", err)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (portal *Portal) UpdateTopic(topic string, setBy types.JID, intent *appservice.IntentAPI, updateInfo bool) bool {
|
|
if portal.Topic != topic {
|
|
portal.log.Debugfln("Updating topic %s -> %s", portal.Topic, topic)
|
|
portal.Topic = topic
|
|
if intent == nil {
|
|
intent = portal.MainIntent()
|
|
if !setBy.IsEmpty() {
|
|
intent = portal.bridge.GetPuppetByJID(setBy).IntentFor(portal)
|
|
}
|
|
}
|
|
_, err := intent.SetRoomTopic(portal.MXID, topic)
|
|
if err == nil {
|
|
if updateInfo {
|
|
portal.UpdateBridgeInfo()
|
|
}
|
|
return true
|
|
} else {
|
|
portal.Topic = ""
|
|
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.IsStatusBroadcastList() {
|
|
update := false
|
|
update = portal.UpdateName(StatusBroadcastName, types.EmptyJID, nil, false) || update
|
|
update = portal.UpdateTopic(StatusBroadcastTopic, types.EmptyJID, nil, false) || update
|
|
return update
|
|
} else if portal.IsBroadcastList() {
|
|
update := false
|
|
//broadcastMetadata, err := user.Conn.GetBroadcastMetadata(portal.Key.JID)
|
|
//if err == nil && broadcastMetadata.Status == 200 {
|
|
// portal.SyncBroadcastRecipients(user, broadcastMetadata)
|
|
// update = portal.UpdateName(broadcastMetadata.Name, "", nil, false) || update
|
|
//} else {
|
|
// user.Conn.Store.ContactsLock.RLock()
|
|
// contact, _ := user.Conn.Store.Contacts[portal.Key.JID]
|
|
// user.Conn.Store.ContactsLock.RUnlock()
|
|
// update = portal.UpdateName(contact.Name, "", nil, false) || update
|
|
//}
|
|
//update = portal.UpdateTopic(BroadcastTopic, "", nil, false) || update
|
|
return update
|
|
}
|
|
metadata, err := user.Client.GetGroupInfo(portal.Key.JID)
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to get group info:", err)
|
|
return false
|
|
}
|
|
|
|
portal.SyncParticipants(user, metadata)
|
|
update := false
|
|
update = portal.UpdateName(metadata.Name, metadata.NameSetBy, nil, false) || update
|
|
update = portal.UpdateTopic(metadata.Topic, metadata.TopicSetBy, nil, false) || update
|
|
|
|
portal.RestrictMessageSending(metadata.IsAnnounce)
|
|
portal.RestrictMetadataChanges(metadata.IsLocked)
|
|
|
|
return update
|
|
}
|
|
|
|
func (portal *Portal) userMXIDAction(user *User, fn func(mxid id.UserID)) {
|
|
if user == nil {
|
|
return
|
|
}
|
|
|
|
if user == portal.bridge.Relaybot {
|
|
for _, mxid := range portal.bridge.Config.Bridge.Relaybot.InviteUsers {
|
|
fn(mxid)
|
|
}
|
|
} else {
|
|
fn(user.MXID)
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) ensureMXIDInvited(mxid id.UserID) {
|
|
err := portal.MainIntent().EnsureInvited(portal.MXID, mxid)
|
|
if err != nil {
|
|
portal.log.Warnfln("Failed to ensure %s is invited to %s: %v", mxid, portal.MXID, err)
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) ensureUserInvited(user *User) {
|
|
if user.IsRelaybot {
|
|
portal.userMXIDAction(user, portal.ensureMXIDInvited)
|
|
return
|
|
}
|
|
|
|
inviteContent := event.Content{
|
|
Parsed: &event.MemberEventContent{
|
|
Membership: event.MembershipInvite,
|
|
IsDirect: portal.IsPrivateChat(),
|
|
},
|
|
Raw: map[string]interface{}{},
|
|
}
|
|
customPuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID)
|
|
if customPuppet != nil && customPuppet.CustomIntent() != nil {
|
|
inviteContent.Raw["fi.mau.will_auto_accept"] = true
|
|
}
|
|
_, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateMember, user.MXID.String(), &inviteContent)
|
|
var httpErr mautrix.HTTPError
|
|
if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") {
|
|
portal.bridge.StateStore.SetMembership(portal.MXID, user.MXID, event.MembershipJoin)
|
|
} else if err != nil {
|
|
portal.log.Warnfln("Failed to invite %s: %v", user.MXID, err)
|
|
}
|
|
|
|
if customPuppet != nil && customPuppet.CustomIntent() != nil {
|
|
err = customPuppet.CustomIntent().EnsureJoined(portal.MXID)
|
|
if err != nil {
|
|
portal.log.Warnfln("Failed to auto-join portal as %s: %v", user.MXID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) Sync(user *User) bool {
|
|
portal.log.Infoln("Syncing portal for", user.MXID)
|
|
|
|
if user.IsRelaybot {
|
|
yes := true
|
|
portal.hasRelaybot = &yes
|
|
}
|
|
|
|
if len(portal.MXID) == 0 {
|
|
err := portal.CreateMatrixRoom(user)
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to create portal room:", err)
|
|
return false
|
|
}
|
|
} else {
|
|
portal.ensureUserInvited(user)
|
|
}
|
|
|
|
update := false
|
|
update = portal.UpdateMetadata(user) || update
|
|
if !portal.IsPrivateChat() && !portal.IsBroadcastList() && portal.Avatar == "" {
|
|
update = portal.UpdateAvatar(user, false) || update
|
|
}
|
|
if update {
|
|
portal.Update()
|
|
portal.UpdateBridgeInfo()
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (portal *Portal) GetBasePowerLevels() *event.PowerLevelsEventContent {
|
|
anyone := 0
|
|
nope := 99
|
|
invite := 50
|
|
if portal.bridge.Config.Bridge.AllowUserInvite {
|
|
invite = 0
|
|
}
|
|
return &event.PowerLevelsEventContent{
|
|
UsersDefault: anyone,
|
|
EventsDefault: anyone,
|
|
RedactPtr: &anyone,
|
|
StateDefaultPtr: &nope,
|
|
BanPtr: &nope,
|
|
InvitePtr: &invite,
|
|
Users: map[id.UserID]int{
|
|
portal.MainIntent().UserID: 100,
|
|
},
|
|
Events: map[string]int{
|
|
event.StateRoomName.Type: anyone,
|
|
event.StateRoomAvatar.Type: anyone,
|
|
event.StateTopic.Type: anyone,
|
|
},
|
|
}
|
|
}
|
|
|
|
//func (portal *Portal) ChangeAdminStatus(jids []string, setAdmin bool) id.EventID {
|
|
// 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 {
|
|
// resp, err := portal.MainIntent().SetPowerLevels(portal.MXID, levels)
|
|
// if err != nil {
|
|
// portal.log.Errorln("Failed to change power levels:", err)
|
|
// } else {
|
|
// return resp.EventID
|
|
// }
|
|
// }
|
|
// return ""
|
|
//}
|
|
|
|
func (portal *Portal) RestrictMessageSending(restrict bool) id.EventID {
|
|
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
|
|
if err != nil {
|
|
levels = portal.GetBasePowerLevels()
|
|
}
|
|
|
|
newLevel := 0
|
|
if restrict {
|
|
newLevel = 50
|
|
}
|
|
|
|
if levels.EventsDefault == newLevel {
|
|
return ""
|
|
}
|
|
|
|
levels.EventsDefault = newLevel
|
|
resp, err := portal.MainIntent().SetPowerLevels(portal.MXID, levels)
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to change power levels:", err)
|
|
return ""
|
|
} else {
|
|
return resp.EventID
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) RestrictMetadataChanges(restrict bool) id.EventID {
|
|
levels, err := portal.MainIntent().PowerLevels(portal.MXID)
|
|
if err != nil {
|
|
levels = portal.GetBasePowerLevels()
|
|
}
|
|
newLevel := 0
|
|
if restrict {
|
|
newLevel = 50
|
|
}
|
|
changed := false
|
|
changed = levels.EnsureEventLevel(event.StateRoomName, newLevel) || changed
|
|
changed = levels.EnsureEventLevel(event.StateRoomAvatar, newLevel) || changed
|
|
changed = levels.EnsureEventLevel(event.StateTopic, newLevel) || changed
|
|
if changed {
|
|
resp, err := portal.MainIntent().SetPowerLevels(portal.MXID, levels)
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to change power levels:", err)
|
|
} else {
|
|
return resp.EventID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (portal *Portal) parseWebMessageInfo(webMsg *waProto.WebMessageInfo) *types.MessageInfo {
|
|
info := types.MessageInfo{
|
|
MessageSource: types.MessageSource{
|
|
Chat: portal.Key.JID,
|
|
IsFromMe: webMsg.GetKey().GetFromMe(),
|
|
IsGroup: false,
|
|
},
|
|
ID: webMsg.GetKey().GetId(),
|
|
PushName: webMsg.GetPushName(),
|
|
Timestamp: time.Unix(int64(webMsg.GetMessageTimestamp()), 0),
|
|
}
|
|
var err error
|
|
if info.IsFromMe {
|
|
info.Sender = portal.Key.Receiver
|
|
} else if portal.IsPrivateChat() {
|
|
info.Sender = portal.Key.JID
|
|
} else if webMsg.GetParticipant() != "" {
|
|
info.Sender, err = types.ParseJID(webMsg.GetParticipant())
|
|
} else if webMsg.GetKey().GetParticipant() != "" {
|
|
info.Sender, err = types.ParseJID(webMsg.GetKey().GetParticipant())
|
|
}
|
|
if info.Sender.IsEmpty() {
|
|
portal.log.Warnfln("Failed to get sender of message %s (parse error: %v)", info.ID, err)
|
|
return nil
|
|
}
|
|
return &info
|
|
}
|
|
|
|
const backfillIDField = "net.maunium.whatsapp.id"
|
|
|
|
func (portal *Portal) wrapBatchEvent(info *types.MessageInfo, intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent) (*event.Event, error) {
|
|
wrappedContent := event.Content{
|
|
Parsed: content,
|
|
Raw: map[string]interface{}{
|
|
backfillIDField: info.ID,
|
|
},
|
|
}
|
|
if intent.IsCustomPuppet {
|
|
wrappedContent.Raw["net.maunium.whatsapp.puppet"] = intent.IsCustomPuppet
|
|
}
|
|
newEventType, err := portal.encrypt(&wrappedContent, eventType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &event.Event{
|
|
Sender: intent.UserID,
|
|
Type: newEventType,
|
|
Timestamp: info.Timestamp.UnixMilli(),
|
|
Content: wrappedContent,
|
|
}, nil
|
|
}
|
|
|
|
func (portal *Portal) appendBatchEvents(converted *ConvertedMessage, info *types.MessageInfo, eventsArray *[]*event.Event, infoArray *[]*types.MessageInfo) error {
|
|
mainEvt, err := portal.wrapBatchEvent(info, converted.Intent, converted.Type, converted.Content)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if converted.Caption != nil {
|
|
captionEvt, err := portal.wrapBatchEvent(info, converted.Intent, converted.Type, converted.Caption)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*eventsArray = append(*eventsArray, mainEvt, captionEvt)
|
|
*infoArray = append(*infoArray, nil, info)
|
|
} else {
|
|
*eventsArray = append(*eventsArray, mainEvt)
|
|
*infoArray = append(*infoArray, info)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (portal *Portal) finishBatch(eventIDs []id.EventID, infos []*types.MessageInfo) {
|
|
if len(eventIDs) != len(infos) {
|
|
portal.log.Errorfln("Length of event IDs (%d) and message infos (%d) doesn't match! Using slow path for mapping event IDs", len(eventIDs), len(infos))
|
|
infoMap := make(map[types.MessageID]*types.MessageInfo, len(infos))
|
|
for _, info := range infos {
|
|
infoMap[info.ID] = info
|
|
}
|
|
for _, eventID := range eventIDs {
|
|
if evt, err := portal.MainIntent().GetEvent(portal.MXID, eventID); err != nil {
|
|
portal.log.Warnfln("Failed to get event %s to register it in the database: %v", eventID, err)
|
|
} else if msgID, ok := evt.Content.Raw[backfillIDField].(string); !ok {
|
|
portal.log.Warnfln("Event %s doesn't include the WhatsApp message ID", eventID)
|
|
} else if info, ok := infoMap[types.MessageID(msgID)]; !ok {
|
|
portal.log.Warnfln("Didn't find info of message %s (event %s) to register it in the database", msgID, eventID)
|
|
} else {
|
|
portal.markHandled(info, eventID, true, false)
|
|
}
|
|
}
|
|
} else {
|
|
for i := 0; i < len(infos); i++ {
|
|
if infos[i] != nil {
|
|
portal.markHandled(infos[i], eventIDs[i], true, false)
|
|
}
|
|
}
|
|
portal.log.Infofln("Successfully sent %d events", len(eventIDs))
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) backfill(source *User, messages []*waProto.HistorySyncMsg) {
|
|
portal.backfillLock.Lock()
|
|
defer portal.backfillLock.Unlock()
|
|
|
|
var historyBatch, newBatch mautrix.ReqBatchSend
|
|
var historyBatchInfos, newBatchInfos []*types.MessageInfo
|
|
|
|
firstMsgTimestamp := time.Unix(int64(messages[len(messages)-1].GetMessage().GetMessageTimestamp()), 0)
|
|
|
|
historyBatch.StateEventsAtStart = make([]*event.Event, 1)
|
|
newBatch.StateEventsAtStart = make([]*event.Event, 1)
|
|
emptyStr := ""
|
|
dummyStateEvent := event.Event{
|
|
Type: BackfillDummyStateEvent,
|
|
Sender: portal.MainIntent().UserID,
|
|
StateKey: &emptyStr,
|
|
Timestamp: firstMsgTimestamp.UnixMilli(),
|
|
Content: event.Content{},
|
|
}
|
|
historyBatch.StateEventsAtStart[0] = &dummyStateEvent
|
|
newBatch.StateEventsAtStart[0] = &dummyStateEvent
|
|
addedMembers := make(map[id.UserID]*event.MemberEventContent)
|
|
|
|
addMember := func(puppet *Puppet) {
|
|
if _, alreadyAdded := addedMembers[puppet.MXID]; alreadyAdded {
|
|
return
|
|
}
|
|
mxid := puppet.MXID.String()
|
|
content := event.MemberEventContent{
|
|
Membership: event.MembershipJoin,
|
|
Displayname: puppet.Displayname,
|
|
AvatarURL: puppet.AvatarURL.CUString(),
|
|
}
|
|
inviteContent := content
|
|
inviteContent.Membership = event.MembershipInvite
|
|
historyBatch.StateEventsAtStart = append(historyBatch.StateEventsAtStart, &event.Event{
|
|
Type: event.StateMember,
|
|
Sender: portal.MainIntent().UserID,
|
|
StateKey: &mxid,
|
|
Timestamp: firstMsgTimestamp.UnixMilli(),
|
|
Content: event.Content{Parsed: &inviteContent},
|
|
}, &event.Event{
|
|
Type: event.StateMember,
|
|
Sender: puppet.MXID,
|
|
StateKey: &mxid,
|
|
Timestamp: firstMsgTimestamp.UnixMilli(),
|
|
Content: event.Content{Parsed: &content},
|
|
})
|
|
addedMembers[puppet.MXID] = &content
|
|
}
|
|
|
|
firstMessage := portal.bridge.DB.Message.GetFirstInChat(portal.Key)
|
|
lastMessage := portal.bridge.DB.Message.GetLastInChat(portal.Key)
|
|
var historyMaxTs, newMinTs time.Time
|
|
|
|
if portal.FirstEventID != "" || portal.NextBatchID != "" {
|
|
historyBatch.PrevEventID = portal.FirstEventID
|
|
historyBatch.BatchID = portal.NextBatchID
|
|
if firstMessage == nil && lastMessage == nil {
|
|
historyMaxTs = time.Now()
|
|
} else {
|
|
historyMaxTs = firstMessage.Timestamp
|
|
}
|
|
}
|
|
if lastMessage != nil {
|
|
newBatch.PrevEventID = lastMessage.MXID
|
|
newMinTs = lastMessage.Timestamp
|
|
}
|
|
|
|
portal.log.Infofln("Processing history sync with %d messages", len(messages))
|
|
// The messages are ordered newest to oldest, so iterate them in reverse order.
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
wrappedMsg := messages[i]
|
|
webMsg := wrappedMsg.GetMessage()
|
|
msgType := portal.getMessageType(webMsg.GetMessage())
|
|
if msgType == "unknown" || msgType == "ignore" {
|
|
if msgType == "unknown" {
|
|
portal.log.Debugfln("Skipping message %s with unknown type in backfill", webMsg.GetKey().GetId())
|
|
}
|
|
continue
|
|
}
|
|
info := portal.parseWebMessageInfo(webMsg)
|
|
if info == nil {
|
|
continue
|
|
}
|
|
var batch *mautrix.ReqBatchSend
|
|
var infos *[]*types.MessageInfo
|
|
var history bool
|
|
if !historyMaxTs.IsZero() && info.Timestamp.Before(historyMaxTs) {
|
|
batch, infos, history = &historyBatch, &historyBatchInfos, true
|
|
} else if !newMinTs.IsZero() && info.Timestamp.After(newMinTs) {
|
|
batch, infos = &newBatch, &newBatchInfos
|
|
} else {
|
|
continue
|
|
}
|
|
puppet := portal.getMessagePuppet(source, info)
|
|
var intent *appservice.IntentAPI
|
|
if portal.Key.JID == puppet.JID {
|
|
intent = puppet.DefaultIntent()
|
|
} else {
|
|
intent = puppet.IntentFor(portal)
|
|
if intent.IsCustomPuppet && !portal.bridge.Config.Bridge.DoublePuppetBackfill {
|
|
intent = puppet.DefaultIntent()
|
|
addMember(puppet)
|
|
}
|
|
}
|
|
converted := portal.convertMessage(intent, source, info, webMsg.GetMessage())
|
|
if converted == nil {
|
|
portal.log.Debugfln("Skipping unsupported message %s in backfill", info.ID)
|
|
continue
|
|
}
|
|
if history && !portal.IsPrivateChat() && !portal.bridge.StateStore.IsInRoom(portal.MXID, puppet.MXID) {
|
|
addMember(puppet)
|
|
}
|
|
err := portal.appendBatchEvents(converted, info, &batch.Events, infos)
|
|
if err != nil {
|
|
portal.log.Errorfln("Error handling message %s during backfill: %v", info.ID, err)
|
|
}
|
|
}
|
|
|
|
if len(historyBatch.Events) > 0 {
|
|
portal.log.Infofln("Sending %d historical messages...", len(historyBatch.Events))
|
|
historyResp, err := portal.MainIntent().BatchSend(portal.MXID, &historyBatch)
|
|
if err != nil {
|
|
portal.log.Errorln("Error sending batch of historical messages:", err)
|
|
} else {
|
|
portal.finishBatch(historyResp.EventIDs, historyBatchInfos)
|
|
portal.NextBatchID = historyResp.NextBatchID
|
|
portal.Update()
|
|
// If batchID is non-empty, it means this is backfilling very old messages, and we don't need a post-backfill dummy.
|
|
if historyBatch.BatchID == "" {
|
|
portal.sendPostBackfillDummy(time.UnixMilli(historyBatch.Events[len(historyBatch.Events)-1].Timestamp))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(newBatch.Events) > 0 {
|
|
portal.log.Debugln("Sending a dummy event to avoid forward extremity errors with forward backfill")
|
|
_, err := portal.MainIntent().SendMessageEvent(portal.MXID, ForwardBackfillDummyEvent, struct{}{})
|
|
if err != nil {
|
|
portal.log.Warnln("Error sending dummy event for forward backfill:", err)
|
|
}
|
|
portal.log.Infofln("Sending %d new messages...", len(newBatch.Events))
|
|
newResp, err := portal.MainIntent().BatchSend(portal.MXID, &newBatch)
|
|
if err != nil {
|
|
portal.log.Errorln("Error sending batch of new messages:", err)
|
|
} else {
|
|
portal.finishBatch(newResp.EventIDs, newBatchInfos)
|
|
portal.sendPostBackfillDummy(time.UnixMilli(newBatch.Events[len(newBatch.Events)-1].Timestamp))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) sendPostBackfillDummy(lastTimestamp time.Time) {
|
|
resp, err := portal.MainIntent().SendMessageEvent(portal.MXID, BackfillEndDummyEvent, struct{}{})
|
|
if err != nil {
|
|
portal.log.Errorln("Error sending post-backfill dummy event:", err)
|
|
return
|
|
}
|
|
msg := portal.bridge.DB.Message.New()
|
|
msg.Chat = portal.Key
|
|
msg.MXID = resp.EventID
|
|
msg.JID = types.MessageID(resp.EventID)
|
|
msg.Timestamp = lastTimestamp.Add(1 * time.Second)
|
|
msg.Sent = true
|
|
msg.Insert()
|
|
}
|
|
|
|
type BridgeInfoSection struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"displayname,omitempty"`
|
|
AvatarURL id.ContentURIString `json:"avatar_url,omitempty"`
|
|
ExternalURL string `json:"external_url,omitempty"`
|
|
}
|
|
|
|
type BridgeInfoContent struct {
|
|
BridgeBot id.UserID `json:"bridgebot"`
|
|
Creator id.UserID `json:"creator,omitempty"`
|
|
Protocol BridgeInfoSection `json:"protocol"`
|
|
Network *BridgeInfoSection `json:"network,omitempty"`
|
|
Channel BridgeInfoSection `json:"channel"`
|
|
}
|
|
|
|
var (
|
|
StateBridgeInfo = event.Type{Type: "m.bridge", Class: event.StateEventType}
|
|
StateHalfShotBridgeInfo = event.Type{Type: "uk.half-shot.bridge", Class: event.StateEventType}
|
|
)
|
|
|
|
func (portal *Portal) getBridgeInfo() (string, BridgeInfoContent) {
|
|
bridgeInfo := BridgeInfoContent{
|
|
BridgeBot: portal.bridge.Bot.UserID,
|
|
Creator: portal.MainIntent().UserID,
|
|
Protocol: BridgeInfoSection{
|
|
ID: "whatsapp",
|
|
DisplayName: "WhatsApp",
|
|
AvatarURL: id.ContentURIString(portal.bridge.Config.AppService.Bot.Avatar),
|
|
ExternalURL: "https://www.whatsapp.com/",
|
|
},
|
|
Channel: BridgeInfoSection{
|
|
ID: portal.Key.JID.String(),
|
|
DisplayName: portal.Name,
|
|
AvatarURL: portal.AvatarURL.CUString(),
|
|
},
|
|
}
|
|
bridgeInfoStateKey := fmt.Sprintf("net.maunium.whatsapp://whatsapp/%s", portal.Key.JID)
|
|
return bridgeInfoStateKey, bridgeInfo
|
|
}
|
|
|
|
func (portal *Portal) UpdateBridgeInfo() {
|
|
if len(portal.MXID) == 0 {
|
|
portal.log.Debugln("Not updating bridge info: no Matrix room created")
|
|
return
|
|
}
|
|
portal.log.Debugln("Updating bridge info...")
|
|
stateKey, content := portal.getBridgeInfo()
|
|
_, err := portal.MainIntent().SendStateEvent(portal.MXID, StateBridgeInfo, stateKey, content)
|
|
if err != nil {
|
|
portal.log.Warnln("Failed to update m.bridge:", err)
|
|
}
|
|
_, err = portal.MainIntent().SendStateEvent(portal.MXID, StateHalfShotBridgeInfo, stateKey, content)
|
|
if err != nil {
|
|
portal.log.Warnln("Failed to update uk.half-shot.bridge:", err)
|
|
}
|
|
}
|
|
|
|
var PortalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType}
|
|
var BackfillDummyStateEvent = event.Type{Type: "fi.mau.dummy.blank_backfill_state", Class: event.StateEventType}
|
|
var BackfillEndDummyEvent = event.Type{Type: "fi.mau.dummy.backfill_end", Class: event.MessageEventType}
|
|
var ForwardBackfillDummyEvent = event.Type{Type: "fi.mau.dummy.pre_forward_backfill", Class: event.MessageEventType}
|
|
|
|
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 *types.GroupInfo
|
|
//var broadcastMetadata *types.BroadcastListInfo
|
|
if portal.IsPrivateChat() {
|
|
puppet := portal.bridge.GetPuppetByJID(portal.Key.JID)
|
|
puppet.SyncContact(user, true)
|
|
if portal.bridge.Config.Bridge.PrivateChatPortalMeta {
|
|
portal.Name = puppet.Displayname
|
|
portal.AvatarURL = puppet.AvatarURL
|
|
portal.Avatar = puppet.Avatar
|
|
} else {
|
|
portal.Name = ""
|
|
}
|
|
portal.Topic = PrivateChatTopic
|
|
} else if portal.IsStatusBroadcastList() {
|
|
if !portal.bridge.Config.Bridge.EnableStatusBroadcast {
|
|
portal.log.Debugln("Status bridging is disabled in config, not creating room after all")
|
|
return ErrStatusBroadcastDisabled
|
|
}
|
|
portal.Name = StatusBroadcastName
|
|
portal.Topic = StatusBroadcastTopic
|
|
} else if portal.IsBroadcastList() {
|
|
//var err error
|
|
//broadcastMetadata, err = user.Conn.GetBroadcastMetadata(portal.Key.JID)
|
|
//if err == nil && broadcastMetadata.Status == 200 {
|
|
// portal.Name = broadcastMetadata.Name
|
|
//} else {
|
|
// user.Conn.Store.ContactsLock.RLock()
|
|
// contact, _ := user.Conn.Store.Contacts[portal.Key.JID]
|
|
// user.Conn.Store.ContactsLock.RUnlock()
|
|
// portal.Name = contact.Name
|
|
//}
|
|
//if len(portal.Name) == 0 {
|
|
// portal.Name = UnnamedBroadcastName
|
|
//}
|
|
//portal.Topic = BroadcastTopic
|
|
portal.log.Debugln("Broadcast list is not yet supported, not creating room after all")
|
|
return fmt.Errorf("broadcast list bridging is currently not supported")
|
|
} else {
|
|
var err error
|
|
metadata, err = user.Client.GetGroupInfo(portal.Key.JID)
|
|
if err == nil {
|
|
portal.Name = metadata.Name
|
|
portal.Topic = metadata.Topic
|
|
}
|
|
portal.UpdateAvatar(user, false)
|
|
}
|
|
|
|
bridgeInfoStateKey, bridgeInfo := portal.getBridgeInfo()
|
|
|
|
initialState := []*event.Event{{
|
|
Type: event.StatePowerLevels,
|
|
Content: event.Content{
|
|
Parsed: portal.GetBasePowerLevels(),
|
|
},
|
|
}, {
|
|
Type: StateBridgeInfo,
|
|
Content: event.Content{Parsed: bridgeInfo},
|
|
StateKey: &bridgeInfoStateKey,
|
|
}, {
|
|
// TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
|
|
Type: StateHalfShotBridgeInfo,
|
|
Content: event.Content{Parsed: bridgeInfo},
|
|
StateKey: &bridgeInfoStateKey,
|
|
}}
|
|
if !portal.AvatarURL.IsEmpty() {
|
|
initialState = append(initialState, &event.Event{
|
|
Type: event.StateRoomAvatar,
|
|
Content: event.Content{
|
|
Parsed: event.RoomAvatarEventContent{URL: portal.AvatarURL},
|
|
},
|
|
})
|
|
}
|
|
|
|
var invite []id.UserID
|
|
if user.IsRelaybot {
|
|
invite = portal.bridge.Config.Bridge.Relaybot.InviteUsers
|
|
}
|
|
|
|
if portal.bridge.Config.Bridge.Encryption.Default {
|
|
initialState = append(initialState, &event.Event{
|
|
Type: event.StateEncryption,
|
|
Content: event.Content{
|
|
Parsed: event.EncryptionEventContent{Algorithm: id.AlgorithmMegolmV1},
|
|
},
|
|
})
|
|
portal.Encrypted = true
|
|
if portal.IsPrivateChat() {
|
|
invite = append(invite, portal.bridge.Bot.UserID)
|
|
}
|
|
}
|
|
|
|
resp, err := intent.CreateRoom(&mautrix.ReqCreateRoom{
|
|
Visibility: "private",
|
|
Name: portal.Name,
|
|
Topic: portal.Topic,
|
|
Invite: invite,
|
|
Preset: "private_chat",
|
|
IsDirect: portal.IsPrivateChat(),
|
|
InitialState: initialState,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
portal.MXID = resp.RoomID
|
|
portal.Update()
|
|
portal.bridge.portalsLock.Lock()
|
|
portal.bridge.portalsByMXID[portal.MXID] = portal
|
|
portal.bridge.portalsLock.Unlock()
|
|
|
|
// We set the memberships beforehand to make sure the encryption key exchange in initial backfill knows the users are here.
|
|
for _, userID := range invite {
|
|
portal.bridge.StateStore.SetMembership(portal.MXID, userID, event.MembershipInvite)
|
|
}
|
|
|
|
portal.ensureUserInvited(user)
|
|
|
|
if metadata != nil {
|
|
portal.SyncParticipants(user, metadata)
|
|
if metadata.IsAnnounce {
|
|
portal.RestrictMessageSending(metadata.IsAnnounce)
|
|
}
|
|
if metadata.IsLocked {
|
|
portal.RestrictMetadataChanges(metadata.IsLocked)
|
|
}
|
|
}
|
|
//if broadcastMetadata != nil {
|
|
// portal.SyncBroadcastRecipients(user, broadcastMetadata)
|
|
//}
|
|
if portal.IsPrivateChat() && !user.IsRelaybot {
|
|
puppet := user.bridge.GetPuppetByJID(portal.Key.JID)
|
|
|
|
if portal.bridge.Config.Bridge.Encryption.Default {
|
|
err = portal.bridge.Bot.EnsureJoined(portal.MXID)
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to join created portal with bridge bot for e2be:", err)
|
|
}
|
|
}
|
|
|
|
user.UpdateDirectChats(map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}})
|
|
}
|
|
|
|
firstEventResp, err := portal.MainIntent().SendMessageEvent(portal.MXID, PortalCreationDummyEvent, struct{}{})
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to send dummy event to mark portal creation:", err)
|
|
} else {
|
|
portal.FirstEventID = firstEventResp.EventID
|
|
portal.Update()
|
|
}
|
|
|
|
//user.CreateUserPortal(database.PortalKeyWithMeta{PortalKey: portal.Key, InCommunity: inCommunity})
|
|
|
|
//err = portal.FillInitialHistory(user)
|
|
//if err != nil {
|
|
// portal.log.Errorln("Failed to fill history:", err)
|
|
//}
|
|
return nil
|
|
}
|
|
|
|
func (portal *Portal) IsPrivateChat() bool {
|
|
return portal.Key.JID.Server == types.DefaultUserServer
|
|
}
|
|
|
|
func (portal *Portal) IsBroadcastList() bool {
|
|
return portal.Key.JID.Server == types.BroadcastServer
|
|
}
|
|
|
|
func (portal *Portal) IsStatusBroadcastList() bool {
|
|
return portal.Key.JID == types.StatusBroadcastJID
|
|
}
|
|
|
|
func (portal *Portal) HasRelaybot() bool {
|
|
if portal.bridge.Relaybot == nil {
|
|
return false
|
|
} else if portal.hasRelaybot == nil {
|
|
// FIXME
|
|
//val := portal.bridge.Relaybot.IsInPortal(portal.Key)
|
|
val := true
|
|
portal.hasRelaybot = &val
|
|
}
|
|
return *portal.hasRelaybot
|
|
}
|
|
|
|
func (portal *Portal) MainIntent() *appservice.IntentAPI {
|
|
if portal.IsPrivateChat() {
|
|
return portal.bridge.GetPuppetByJID(portal.Key.JID).DefaultIntent()
|
|
}
|
|
return portal.bridge.Bot
|
|
}
|
|
|
|
func (portal *Portal) SetReply(content *event.MessageEventContent, replyToID types.MessageID) {
|
|
if len(replyToID) == 0 {
|
|
return
|
|
}
|
|
message := portal.bridge.DB.Message.GetByJID(portal.Key, replyToID)
|
|
if message != nil && !message.IsFakeMXID() {
|
|
evt, err := portal.MainIntent().GetEvent(portal.MXID, message.MXID)
|
|
if err != nil {
|
|
portal.log.Warnln("Failed to get reply target:", err)
|
|
return
|
|
}
|
|
if evt.Type == event.EventEncrypted {
|
|
_ = evt.Content.ParseRaw(evt.Type)
|
|
decryptedEvt, err := portal.bridge.Crypto.Decrypt(evt)
|
|
if err != nil {
|
|
portal.log.Warnln("Failed to decrypt reply target:", err)
|
|
} else {
|
|
evt = decryptedEvt
|
|
}
|
|
}
|
|
_ = evt.Content.ParseRaw(evt.Type)
|
|
content.SetReply(evt)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (portal *Portal) HandleMessageRevoke(user *User, key *waProto.MessageKey) bool {
|
|
msg := portal.bridge.DB.Message.GetByJID(portal.Key, key.GetId())
|
|
if msg == nil || msg.IsFakeMXID() {
|
|
return false
|
|
}
|
|
var intent *appservice.IntentAPI
|
|
if key.GetFromMe() {
|
|
if portal.IsPrivateChat() {
|
|
intent = portal.bridge.GetPuppetByJID(user.JID).CustomIntent()
|
|
} else {
|
|
intent = portal.bridge.GetPuppetByJID(user.JID).IntentFor(portal)
|
|
}
|
|
} else if len(key.GetParticipant()) > 0 {
|
|
jid, err := types.ParseJID(key.GetParticipant())
|
|
if err != nil {
|
|
return false
|
|
}
|
|
intent = portal.bridge.GetPuppetByJID(jid).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)
|
|
} else {
|
|
msg.Delete()
|
|
}
|
|
return true
|
|
}
|
|
|
|
//func (portal *Portal) HandleFakeMessage(_ *User, message FakeMessage) bool {
|
|
// if portal.isRecentlyHandled(message.ID) {
|
|
// return false
|
|
// }
|
|
//
|
|
// content := event.MessageEventContent{
|
|
// MsgType: event.MsgNotice,
|
|
// Body: message.Text,
|
|
// }
|
|
// if message.Alert {
|
|
// content.MsgType = event.MsgText
|
|
// }
|
|
// _, err := portal.sendMainIntentMessage(content)
|
|
// if err != nil {
|
|
// portal.log.Errorfln("Failed to handle fake message %s: %v", message.ID, err)
|
|
// return true
|
|
// }
|
|
//
|
|
// portal.recentlyHandledLock.Lock()
|
|
// index := portal.recentlyHandledIndex
|
|
// portal.recentlyHandledIndex = (portal.recentlyHandledIndex + 1) % recentlyHandledLength
|
|
// portal.recentlyHandledLock.Unlock()
|
|
// portal.recentlyHandled[index] = message.ID
|
|
// return true
|
|
//}
|
|
|
|
func (portal *Portal) sendMainIntentMessage(content interface{}) (*mautrix.RespSendEvent, error) {
|
|
return portal.sendMessage(portal.MainIntent(), event.EventMessage, content, 0)
|
|
}
|
|
|
|
func (portal *Portal) encrypt(content *event.Content, eventType event.Type) (event.Type, error) {
|
|
if portal.Encrypted && portal.bridge.Crypto != nil {
|
|
// TODO maybe the locking should be inside mautrix-go?
|
|
portal.encryptLock.Lock()
|
|
encrypted, err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, *content)
|
|
portal.encryptLock.Unlock()
|
|
if err != nil {
|
|
return eventType, fmt.Errorf("failed to encrypt event: %w", err)
|
|
}
|
|
eventType = event.EventEncrypted
|
|
content.Parsed = encrypted
|
|
}
|
|
return eventType, nil
|
|
}
|
|
|
|
func (portal *Portal) sendMessage(intent *appservice.IntentAPI, eventType event.Type, content interface{}, timestamp int64) (*mautrix.RespSendEvent, error) {
|
|
wrappedContent := event.Content{Parsed: content}
|
|
if timestamp != 0 && intent.IsCustomPuppet {
|
|
wrappedContent.Raw = map[string]interface{}{
|
|
"net.maunium.whatsapp.puppet": intent.IsCustomPuppet,
|
|
}
|
|
}
|
|
var err error
|
|
eventType, err = portal.encrypt(&wrappedContent, eventType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, _ = intent.UserTyping(portal.MXID, false, 0)
|
|
if timestamp == 0 {
|
|
return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent)
|
|
} else {
|
|
return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp)
|
|
}
|
|
}
|
|
|
|
type ConvertedMessage struct {
|
|
Intent *appservice.IntentAPI
|
|
Type event.Type
|
|
Content *event.MessageEventContent
|
|
Caption *event.MessageEventContent
|
|
}
|
|
|
|
func (portal *Portal) convertTextMessage(intent *appservice.IntentAPI, msg *waProto.Message) *ConvertedMessage {
|
|
content := &event.MessageEventContent{
|
|
Body: msg.GetConversation(),
|
|
MsgType: event.MsgText,
|
|
}
|
|
if msg.GetExtendedTextMessage() != nil {
|
|
content.Body = msg.GetExtendedTextMessage().GetText()
|
|
|
|
contextInfo := msg.GetExtendedTextMessage().GetContextInfo()
|
|
if contextInfo != nil {
|
|
portal.bridge.Formatter.ParseWhatsApp(content, contextInfo.GetMentionedJid())
|
|
portal.SetReply(content, contextInfo.GetStanzaId())
|
|
}
|
|
}
|
|
|
|
return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content}
|
|
}
|
|
|
|
//func (portal *Portal) HandleStubMessage(source *User, message whatsapp.StubMessage, isBackfill bool) bool {
|
|
// if portal.bridge.Config.Bridge.ChatMetaSync && (!portal.IsBroadcastList() || isBackfill) {
|
|
// // Chat meta sync is enabled, so we use chat update commands and full-syncs instead of message history
|
|
// // However, broadcast lists don't have update commands, so we handle these if it's not a backfill
|
|
// return false
|
|
// }
|
|
// intent := portal.startHandling(source, message.Info, fmt.Sprintf("stub %s", message.Type.String()))
|
|
// if intent == nil {
|
|
// return false
|
|
// }
|
|
// var senderJID string
|
|
// if message.Info.FromMe {
|
|
// senderJID = source.JID
|
|
// } else {
|
|
// senderJID = message.Info.SenderJid
|
|
// }
|
|
// var eventID id.EventID
|
|
// // TODO find more real event IDs
|
|
// // TODO timestamp massaging
|
|
// switch message.Type {
|
|
// case waProto.WebMessageInfo_GROUP_CHANGE_SUBJECT:
|
|
// portal.UpdateName(message.FirstParam, "", intent, true)
|
|
// case waProto.WebMessageInfo_GROUP_CHANGE_ICON:
|
|
// portal.UpdateAvatar(source, nil, true)
|
|
// case waProto.WebMessageInfo_GROUP_CHANGE_DESCRIPTION:
|
|
// if isBackfill {
|
|
// // TODO fetch topic from server
|
|
// }
|
|
// //portal.UpdateTopic(message.FirstParam, "", intent, true)
|
|
// case waProto.WebMessageInfo_GROUP_CHANGE_ANNOUNCE:
|
|
// eventID = portal.RestrictMessageSending(message.FirstParam == "on")
|
|
// case waProto.WebMessageInfo_GROUP_CHANGE_RESTRICT:
|
|
// eventID = portal.RestrictMetadataChanges(message.FirstParam == "on")
|
|
// case waProto.WebMessageInfo_GROUP_PARTICIPANT_ADD, waProto.WebMessageInfo_GROUP_PARTICIPANT_INVITE, waProto.WebMessageInfo_BROADCAST_ADD:
|
|
// eventID = portal.HandleWhatsAppInvite(source, senderJID, intent, message.Params)
|
|
// case waProto.WebMessageInfo_GROUP_PARTICIPANT_REMOVE, waProto.WebMessageInfo_GROUP_PARTICIPANT_LEAVE, waProto.WebMessageInfo_BROADCAST_REMOVE:
|
|
// portal.HandleWhatsAppKick(source, senderJID, message.Params)
|
|
// case waProto.WebMessageInfo_GROUP_PARTICIPANT_PROMOTE:
|
|
// eventID = portal.ChangeAdminStatus(message.Params, true)
|
|
// case waProto.WebMessageInfo_GROUP_PARTICIPANT_DEMOTE:
|
|
// eventID = portal.ChangeAdminStatus(message.Params, false)
|
|
// default:
|
|
// return false
|
|
// }
|
|
// if len(eventID) == 0 {
|
|
// eventID = id.EventID(fmt.Sprintf("net.maunium.whatsapp.fake::%s", message.Info.Id))
|
|
// }
|
|
// portal.markHandled(source, message.Info.Source, eventID, true)
|
|
// return true
|
|
//}
|
|
|
|
func (portal *Portal) convertLocationMessage(intent *appservice.IntentAPI, msg *waProto.LocationMessage) *ConvertedMessage {
|
|
url := msg.GetUrl()
|
|
if len(url) == 0 {
|
|
url = fmt.Sprintf("https://maps.google.com/?q=%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude())
|
|
}
|
|
name := msg.GetName()
|
|
if len(name) == 0 {
|
|
latChar := 'N'
|
|
if msg.GetDegreesLatitude() < 0 {
|
|
latChar = 'S'
|
|
}
|
|
longChar := 'E'
|
|
if msg.GetDegreesLongitude() < 0 {
|
|
longChar = 'W'
|
|
}
|
|
name = fmt.Sprintf("%.4f° %c %.4f° %c", math.Abs(msg.GetDegreesLatitude()), latChar, math.Abs(msg.GetDegreesLongitude()), longChar)
|
|
}
|
|
|
|
content := &event.MessageEventContent{
|
|
MsgType: event.MsgLocation,
|
|
Body: fmt.Sprintf("Location: %s\n%s\n%s", name, msg.GetAddress(), url),
|
|
Format: event.FormatHTML,
|
|
FormattedBody: fmt.Sprintf("Location: <a href='%s'>%s</a><br>%s", url, name, msg.GetAddress()),
|
|
GeoURI: fmt.Sprintf("geo:%.5f,%.5f", msg.GetDegreesLatitude(), msg.GetDegreesLongitude()),
|
|
}
|
|
|
|
if len(msg.GetJpegThumbnail()) > 0 {
|
|
thumbnailMime := http.DetectContentType(msg.GetJpegThumbnail())
|
|
uploadedThumbnail, _ := intent.UploadBytes(msg.GetJpegThumbnail(), thumbnailMime)
|
|
if uploadedThumbnail != nil {
|
|
cfg, _, _ := image.DecodeConfig(bytes.NewReader(msg.GetJpegThumbnail()))
|
|
content.Info = &event.FileInfo{
|
|
ThumbnailInfo: &event.FileInfo{
|
|
Size: len(msg.GetJpegThumbnail()),
|
|
Width: cfg.Width,
|
|
Height: cfg.Height,
|
|
MimeType: thumbnailMime,
|
|
},
|
|
ThumbnailURL: uploadedThumbnail.ContentURI.CUString(),
|
|
}
|
|
}
|
|
}
|
|
|
|
portal.SetReply(content, msg.GetContextInfo().GetStanzaId())
|
|
|
|
return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content}
|
|
}
|
|
|
|
func (portal *Portal) convertContactMessage(intent *appservice.IntentAPI, msg *waProto.ContactMessage) *ConvertedMessage {
|
|
fileName := fmt.Sprintf("%s.vcf", msg.GetDisplayName())
|
|
data := []byte(msg.GetVcard())
|
|
mimeType := "text/vcard"
|
|
data, uploadMimeType, file := portal.encryptFile(data, mimeType)
|
|
|
|
uploadResp, err := intent.UploadBytesWithName(data, uploadMimeType, fileName)
|
|
if err != nil {
|
|
portal.log.Errorfln("Failed to upload vcard of %s: %v", msg.GetDisplayName(), err)
|
|
return nil
|
|
}
|
|
|
|
content := &event.MessageEventContent{
|
|
Body: fileName,
|
|
MsgType: event.MsgFile,
|
|
File: file,
|
|
Info: &event.FileInfo{
|
|
MimeType: mimeType,
|
|
Size: len(msg.GetVcard()),
|
|
},
|
|
}
|
|
if content.File != nil {
|
|
content.File.URL = uploadResp.ContentURI.CUString()
|
|
} else {
|
|
content.URL = uploadResp.ContentURI.CUString()
|
|
}
|
|
|
|
portal.SetReply(content, msg.GetContextInfo().GetStanzaId())
|
|
|
|
return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: content}
|
|
}
|
|
|
|
// FIXME
|
|
//func (portal *Portal) tryKickUser(userID id.UserID, intent *appservice.IntentAPI) error {
|
|
// _, err := intent.KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: userID})
|
|
// if err != nil {
|
|
// httpErr, ok := err.(mautrix.HTTPError)
|
|
// if ok && httpErr.RespError != nil && httpErr.RespError.ErrCode == "M_FORBIDDEN" {
|
|
// _, err = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: userID})
|
|
// }
|
|
// }
|
|
// return err
|
|
//}
|
|
//
|
|
//func (portal *Portal) removeUser(isSameUser bool, kicker *appservice.IntentAPI, target id.UserID, targetIntent *appservice.IntentAPI) {
|
|
// if !isSameUser || targetIntent == nil {
|
|
// err := portal.tryKickUser(target, kicker)
|
|
// if err != nil {
|
|
// portal.log.Warnfln("Failed to kick %s from %s: %v", target, portal.MXID, err)
|
|
// if targetIntent != nil {
|
|
// _, _ = targetIntent.LeaveRoom(portal.MXID)
|
|
// }
|
|
// }
|
|
// } else {
|
|
// _, err := targetIntent.LeaveRoom(portal.MXID)
|
|
// if err != nil {
|
|
// portal.log.Warnfln("Failed to leave portal as %s: %v", target, err)
|
|
// _, _ = portal.MainIntent().KickUser(portal.MXID, &mautrix.ReqKickUser{UserID: target})
|
|
// }
|
|
// }
|
|
//}
|
|
//
|
|
//func (portal *Portal) HandleWhatsAppKick(source *User, senderJID string, jids []string) {
|
|
// sender := portal.bridge.GetPuppetByJID(senderJID)
|
|
// senderIntent := sender.IntentFor(portal)
|
|
// for _, jid := range jids {
|
|
// if source != nil && source.JID == jid {
|
|
// portal.log.Debugln("Ignoring self-kick by", source.MXID)
|
|
// continue
|
|
// }
|
|
// puppet := portal.bridge.GetPuppetByJID(jid)
|
|
// portal.removeUser(puppet.JID == sender.JID, senderIntent, puppet.MXID, puppet.DefaultIntent())
|
|
//
|
|
// if !portal.IsBroadcastList() {
|
|
// user := portal.bridge.GetUserByJID(jid)
|
|
// if user != nil {
|
|
// var customIntent *appservice.IntentAPI
|
|
// if puppet.CustomMXID == user.MXID {
|
|
// customIntent = puppet.CustomIntent()
|
|
// }
|
|
// portal.removeUser(puppet.JID == sender.JID, senderIntent, user.MXID, customIntent)
|
|
// }
|
|
// }
|
|
// }
|
|
//}
|
|
//
|
|
//func (portal *Portal) HandleWhatsAppInvite(source *User, senderJID string, intent *appservice.IntentAPI, jids []string) (evtID id.EventID) {
|
|
// if intent == nil {
|
|
// intent = portal.MainIntent()
|
|
// if senderJID != "unknown" {
|
|
// sender := portal.bridge.GetPuppetByJID(senderJID)
|
|
// intent = sender.IntentFor(portal)
|
|
// }
|
|
// }
|
|
// for _, jid := range jids {
|
|
// puppet := portal.bridge.GetPuppetByJID(jid)
|
|
// puppet.SyncContact(source, true)
|
|
// content := event.Content{
|
|
// Parsed: event.MemberEventContent{
|
|
// Membership: "invite",
|
|
// Displayname: puppet.Displayname,
|
|
// AvatarURL: puppet.AvatarURL.CUString(),
|
|
// },
|
|
// Raw: map[string]interface{}{
|
|
// "net.maunium.whatsapp.puppet": true,
|
|
// },
|
|
// }
|
|
// resp, err := intent.SendStateEvent(portal.MXID, event.StateMember, puppet.MXID.String(), &content)
|
|
// if err != nil {
|
|
// portal.log.Warnfln("Failed to invite %s as %s: %v", puppet.MXID, intent.UserID, err)
|
|
// _ = portal.MainIntent().EnsureInvited(portal.MXID, puppet.MXID)
|
|
// } else {
|
|
// evtID = resp.EventID
|
|
// }
|
|
// err = puppet.DefaultIntent().EnsureJoined(portal.MXID)
|
|
// if err != nil {
|
|
// portal.log.Errorfln("Failed to ensure %s is joined: %v", puppet.MXID, err)
|
|
// }
|
|
// }
|
|
// return
|
|
//}
|
|
|
|
func (portal *Portal) makeMediaBridgeFailureMessage(intent *appservice.IntentAPI, info *types.MessageInfo, bridgeErr error, captionContent *event.MessageEventContent) *ConvertedMessage {
|
|
portal.log.Errorfln("Failed to bridge media for %s: %v", info.ID, bridgeErr)
|
|
return &ConvertedMessage{Intent: intent, Type: event.EventMessage, Content: &event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: "Failed to bridge media",
|
|
}, Caption: captionContent}
|
|
}
|
|
|
|
func (portal *Portal) encryptFile(data []byte, mimeType string) ([]byte, string, *event.EncryptedFileInfo) {
|
|
if !portal.Encrypted {
|
|
return data, mimeType, nil
|
|
}
|
|
|
|
file := &event.EncryptedFileInfo{
|
|
EncryptedFile: *attachment.NewEncryptedFile(),
|
|
URL: "",
|
|
}
|
|
return file.Encrypt(data), "application/octet-stream", file
|
|
}
|
|
|
|
type MediaMessage interface {
|
|
whatsmeow.DownloadableMessage
|
|
GetContextInfo() *waProto.ContextInfo
|
|
GetMimetype() string
|
|
}
|
|
|
|
type MediaMessageWithThumbnail interface {
|
|
MediaMessage
|
|
GetJpegThumbnail() []byte
|
|
GetCaption() string
|
|
}
|
|
|
|
type MediaMessageWithCaption interface {
|
|
MediaMessage
|
|
GetCaption() string
|
|
}
|
|
|
|
type MediaMessageWithFileName interface {
|
|
MediaMessage
|
|
GetFileName() string
|
|
}
|
|
|
|
type MediaMessageWithDuration interface {
|
|
MediaMessage
|
|
GetSeconds() uint32
|
|
}
|
|
|
|
func (portal *Portal) convertMediaMessage(intent *appservice.IntentAPI, source *User, info *types.MessageInfo, msg MediaMessage) *ConvertedMessage {
|
|
messageWithCaption, ok := msg.(MediaMessageWithCaption)
|
|
var captionContent *event.MessageEventContent
|
|
if ok && len(messageWithCaption.GetCaption()) > 0 {
|
|
captionContent = &event.MessageEventContent{
|
|
Body: messageWithCaption.GetCaption(),
|
|
MsgType: event.MsgNotice,
|
|
}
|
|
|
|
portal.bridge.Formatter.ParseWhatsApp(captionContent, msg.GetContextInfo().GetMentionedJid())
|
|
}
|
|
|
|
data, err := source.Client.Download(msg)
|
|
// TODO can these errors still be handled?
|
|
//if errors.Is(err, whatsapp.ErrMediaDownloadFailedWith404) || errors.Is(err, whatsapp.ErrMediaDownloadFailedWith410) {
|
|
// portal.log.Warnfln("Failed to download media for %s: %v. Calling LoadMediaInfo and retrying download...", msg.info.Id, err)
|
|
// _, err = source.Conn.LoadMediaInfo(msg.info.RemoteJid, msg.info.Id, msg.info.FromMe)
|
|
// if err != nil {
|
|
// portal.sendMediaBridgeFailure(source, intent, msg.info, fmt.Errorf("failed to load media info: %w", err))
|
|
// return true
|
|
// }
|
|
// data, err = msg.download()
|
|
//}
|
|
if errors.Is(err, whatsmeow.ErrNoURLPresent) {
|
|
portal.log.Debugfln("No URL present error for media message %s, ignoring...", info.ID)
|
|
return nil
|
|
} else if err != nil {
|
|
return portal.makeMediaBridgeFailureMessage(intent, info, err, captionContent)
|
|
}
|
|
|
|
var width, height int
|
|
if strings.HasPrefix(msg.GetMimetype(), "image/") {
|
|
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
|
|
width, height = cfg.Width, cfg.Height
|
|
}
|
|
|
|
data, uploadMimeType, file := portal.encryptFile(data, msg.GetMimetype())
|
|
|
|
uploaded, err := intent.UploadBytes(data, uploadMimeType)
|
|
if err != nil {
|
|
if errors.Is(err, mautrix.MTooLarge) {
|
|
return portal.makeMediaBridgeFailureMessage(intent, info, errors.New("homeserver rejected too large file"), captionContent)
|
|
} else if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.IsStatus(413) {
|
|
return portal.makeMediaBridgeFailureMessage(intent, info, errors.New("proxy rejected too large file"), captionContent)
|
|
} else {
|
|
return portal.makeMediaBridgeFailureMessage(intent, info, fmt.Errorf("failed to upload media: %w", err), captionContent)
|
|
}
|
|
}
|
|
|
|
content := &event.MessageEventContent{
|
|
File: file,
|
|
Info: &event.FileInfo{
|
|
Size: len(data),
|
|
MimeType: msg.GetMimetype(),
|
|
Width: width,
|
|
Height: height,
|
|
},
|
|
}
|
|
|
|
msgWithName, ok := msg.(MediaMessageWithFileName)
|
|
if ok && len(msgWithName.GetFileName()) > 0 {
|
|
content.Body = msgWithName.GetFileName()
|
|
} else {
|
|
mimeClass := strings.Split(msg.GetMimetype(), "/")[0]
|
|
switch mimeClass {
|
|
case "application":
|
|
content.Body = "file"
|
|
default:
|
|
content.Body = mimeClass
|
|
}
|
|
|
|
exts, _ := mime.ExtensionsByType(msg.GetMimetype())
|
|
if exts != nil && len(exts) > 0 {
|
|
content.Body += exts[0]
|
|
}
|
|
}
|
|
|
|
msgWithDuration, ok := msg.(MediaMessageWithDuration)
|
|
if ok {
|
|
content.Info.Duration = int(msgWithDuration.GetSeconds()) * 1000
|
|
}
|
|
|
|
if content.File != nil {
|
|
content.File.URL = uploaded.ContentURI.CUString()
|
|
} else {
|
|
content.URL = uploaded.ContentURI.CUString()
|
|
}
|
|
portal.SetReply(content, msg.GetContextInfo().GetStanzaId())
|
|
|
|
messageWithThumbnail, ok := msg.(MediaMessageWithThumbnail)
|
|
if ok && messageWithThumbnail.GetJpegThumbnail() != nil && portal.bridge.Config.Bridge.WhatsappThumbnail {
|
|
thumbnailData := messageWithThumbnail.GetJpegThumbnail()
|
|
thumbnailMime := http.DetectContentType(thumbnailData)
|
|
thumbnailCfg, _, _ := image.DecodeConfig(bytes.NewReader(thumbnailData))
|
|
thumbnailSize := len(thumbnailData)
|
|
thumbnail, thumbnailUploadMime, thumbnailFile := portal.encryptFile(thumbnailData, thumbnailMime)
|
|
uploadedThumbnail, err := intent.UploadBytes(thumbnail, thumbnailUploadMime)
|
|
if err != nil {
|
|
portal.log.Warnfln("Failed to upload thumbnail for %s: %v", info.ID, err)
|
|
} else if uploadedThumbnail != nil {
|
|
if thumbnailFile != nil {
|
|
thumbnailFile.URL = uploadedThumbnail.ContentURI.CUString()
|
|
content.Info.ThumbnailFile = thumbnailFile
|
|
} else {
|
|
content.Info.ThumbnailURL = uploadedThumbnail.ContentURI.CUString()
|
|
}
|
|
content.Info.ThumbnailInfo = &event.FileInfo{
|
|
Size: thumbnailSize,
|
|
Width: thumbnailCfg.Width,
|
|
Height: thumbnailCfg.Height,
|
|
MimeType: thumbnailMime,
|
|
}
|
|
}
|
|
}
|
|
|
|
_, isSticker := msg.(*waProto.StickerMessage)
|
|
switch strings.ToLower(strings.Split(msg.GetMimetype(), "/")[0]) {
|
|
case "image":
|
|
if !isSticker {
|
|
content.MsgType = event.MsgImage
|
|
}
|
|
case "video":
|
|
content.MsgType = event.MsgVideo
|
|
case "audio":
|
|
content.MsgType = event.MsgAudio
|
|
default:
|
|
content.MsgType = event.MsgFile
|
|
}
|
|
|
|
eventType := event.EventMessage
|
|
if isSticker {
|
|
eventType = event.EventSticker
|
|
}
|
|
|
|
return &ConvertedMessage{
|
|
Intent: intent,
|
|
Type: eventType,
|
|
Content: content,
|
|
Caption: captionContent,
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) downloadThumbnail(content *event.MessageEventContent, id id.EventID) []byte {
|
|
if len(content.GetInfo().ThumbnailURL) == 0 {
|
|
return nil
|
|
}
|
|
mxc, err := content.GetInfo().ThumbnailURL.Parse()
|
|
if err != nil {
|
|
portal.log.Errorln("Malformed thumbnail URL in %s: %v", id, err)
|
|
}
|
|
thumbnail, err := portal.MainIntent().DownloadBytes(mxc)
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to download thumbnail in %s: %v", 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", id, err)
|
|
return nil
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func (portal *Portal) convertWebPtoPNG(webpImage []byte) ([]byte, error) {
|
|
webpDecoded, err := webp.Decode(bytes.NewReader(webpImage))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode webp image: %w", err)
|
|
}
|
|
|
|
var pngBuffer bytes.Buffer
|
|
if err := png.Encode(&pngBuffer, webpDecoded); err != nil {
|
|
return nil, fmt.Errorf("failed to encode png image: %w", err)
|
|
}
|
|
|
|
return pngBuffer.Bytes(), nil
|
|
}
|
|
|
|
func (portal *Portal) convertGifToVideo(gif []byte) ([]byte, error) {
|
|
dir, err := ioutil.TempDir("", "gif-convert-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to make temp dir: %w", err)
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
|
|
inputFile, err := os.OpenFile(filepath.Join(dir, "input.gif"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed open input file: %w", err)
|
|
}
|
|
_, err = inputFile.Write(gif)
|
|
if err != nil {
|
|
_ = inputFile.Close()
|
|
return nil, fmt.Errorf("failed to write gif to input file: %w", err)
|
|
}
|
|
_ = inputFile.Close()
|
|
|
|
outputFileName := filepath.Join(dir, "output.mp4")
|
|
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "warning",
|
|
"-f", "gif", "-i", inputFile.Name(),
|
|
"-pix_fmt", "yuv420p", "-c:v", "libx264", "-movflags", "+faststart",
|
|
"-filter:v", "crop='floor(in_w/2)*2:floor(in_h/2)*2'",
|
|
outputFileName)
|
|
vcLog := portal.log.Sub("VideoConverter").Writer(log.LevelWarn)
|
|
cmd.Stdout = vcLog
|
|
cmd.Stderr = vcLog
|
|
|
|
err = cmd.Run()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to run ffmpeg: %w", err)
|
|
}
|
|
outputFile, err := os.OpenFile(filepath.Join(dir, "output.mp4"), os.O_RDONLY, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open output file: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = outputFile.Close()
|
|
_ = os.Remove(outputFile.Name())
|
|
}()
|
|
mp4, err := ioutil.ReadAll(outputFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read mp4 from output file: %w", err)
|
|
}
|
|
return mp4, nil
|
|
}
|
|
|
|
func (portal *Portal) preprocessMatrixMedia(sender *User, relaybotFormatted bool, content *event.MessageEventContent, eventID id.EventID, mediaType whatsmeow.MediaType) *MediaUpload {
|
|
var caption string
|
|
var mentionedJIDs []string
|
|
if relaybotFormatted {
|
|
caption, mentionedJIDs = portal.bridge.Formatter.ParseMatrix(content.FormattedBody)
|
|
}
|
|
|
|
var file *event.EncryptedFileInfo
|
|
rawMXC := content.URL
|
|
if content.File != nil {
|
|
file = content.File
|
|
rawMXC = file.URL
|
|
}
|
|
mxc, err := rawMXC.Parse()
|
|
if err != nil {
|
|
portal.log.Errorln("Malformed content URL in %s: %v", eventID, err)
|
|
return nil
|
|
}
|
|
data, err := portal.MainIntent().DownloadBytes(mxc)
|
|
if err != nil {
|
|
portal.log.Errorfln("Failed to download media in %s: %v", eventID, err)
|
|
return nil
|
|
}
|
|
if file != nil {
|
|
data, err = file.Decrypt(data)
|
|
if err != nil {
|
|
portal.log.Errorfln("Failed to decrypt media in %s: %v", eventID, err)
|
|
return nil
|
|
}
|
|
}
|
|
if mediaType == whatsmeow.MediaVideo && content.GetInfo().MimeType == "image/gif" {
|
|
data, err = portal.convertGifToVideo(data)
|
|
if err != nil {
|
|
portal.log.Errorfln("Failed to convert gif to mp4 in %s: %v", eventID, err)
|
|
return nil
|
|
}
|
|
content.Info.MimeType = "video/mp4"
|
|
}
|
|
if mediaType == whatsmeow.MediaImage && content.GetInfo().MimeType == "image/webp" {
|
|
data, err = portal.convertWebPtoPNG(data)
|
|
if err != nil {
|
|
portal.log.Errorfln("Failed to convert webp to png in %s: %v", eventID, err)
|
|
return nil
|
|
}
|
|
content.Info.MimeType = "image/png"
|
|
}
|
|
uploadResp, err := sender.Client.Upload(context.Background(), data, mediaType)
|
|
if err != nil {
|
|
portal.log.Errorfln("Failed to upload media in %s: %v", eventID, err)
|
|
return nil
|
|
}
|
|
|
|
return &MediaUpload{
|
|
UploadResponse: uploadResp,
|
|
Caption: caption,
|
|
MentionedJIDs: mentionedJIDs,
|
|
Thumbnail: portal.downloadThumbnail(content, eventID),
|
|
FileLength: len(data),
|
|
}
|
|
}
|
|
|
|
type MediaUpload struct {
|
|
whatsmeow.UploadResponse
|
|
Caption string
|
|
MentionedJIDs []string
|
|
Thumbnail []byte
|
|
FileLength int
|
|
}
|
|
|
|
func (portal *Portal) sendMatrixConnectionError(sender *User, eventID id.EventID) bool {
|
|
if !sender.HasSession() {
|
|
portal.log.Debugln("Ignoring event", eventID, "from", sender.MXID, "as user has no session")
|
|
return true
|
|
} /*else if !sender.IsConnected() {
|
|
inRoom := ""
|
|
if portal.IsPrivateChat() {
|
|
inRoom = " in your management room"
|
|
}
|
|
if sender.IsLoginInProgress() {
|
|
portal.log.Debugln("Waiting for connection before handling event", eventID, "from", sender.MXID)
|
|
sender.Conn.WaitForLogin()
|
|
if sender.IsConnected() {
|
|
return false
|
|
}
|
|
}
|
|
reconnect := fmt.Sprintf("Use `%s reconnect`%s to reconnect.", portal.bridge.Config.Bridge.CommandPrefix, inRoom)
|
|
portal.log.Debugln("Ignoring event", eventID, "from", sender.MXID, "as user is not connected")
|
|
msg := format.RenderMarkdown("\u26a0 You are not connected to WhatsApp, so your message was not bridged. "+reconnect, true, false)
|
|
msg.MsgType = event.MsgNotice
|
|
_, err := portal.sendMainIntentMessage(msg)
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to send bridging failure message:", err)
|
|
}
|
|
return true
|
|
}*/
|
|
// FIXME implement
|
|
return false
|
|
}
|
|
|
|
func (portal *Portal) addRelaybotFormat(sender *User, content *event.MessageEventContent) bool {
|
|
member := portal.MainIntent().Member(portal.MXID, sender.MXID)
|
|
if len(member.Displayname) == 0 {
|
|
member.Displayname = string(sender.MXID)
|
|
}
|
|
|
|
if content.Format != event.FormatHTML {
|
|
content.FormattedBody = strings.Replace(html.EscapeString(content.Body), "\n", "<br/>", -1)
|
|
content.Format = event.FormatHTML
|
|
}
|
|
data, err := portal.bridge.Config.Bridge.Relaybot.FormatMessage(content, sender.MXID, member)
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to apply relaybot format:", err)
|
|
}
|
|
content.FormattedBody = data
|
|
return true
|
|
}
|
|
|
|
func addCodecToMime(mimeType, codec string) string {
|
|
mediaType, params, err := mime.ParseMediaType(mimeType)
|
|
if err != nil {
|
|
return mimeType
|
|
}
|
|
if _, ok := params["codecs"]; !ok {
|
|
params["codecs"] = codec
|
|
}
|
|
return mime.FormatMediaType(mediaType, params)
|
|
}
|
|
|
|
func parseGeoURI(uri string) (lat, long float64, err error) {
|
|
if !strings.HasPrefix(uri, "geo:") {
|
|
err = fmt.Errorf("uri doesn't have geo: prefix")
|
|
return
|
|
}
|
|
// Remove geo: prefix and anything after ;
|
|
coordinates := strings.Split(strings.TrimPrefix(uri, "geo:"), ";")[0]
|
|
|
|
if splitCoordinates := strings.Split(coordinates, ","); len(splitCoordinates) != 2 {
|
|
err = fmt.Errorf("didn't find exactly two numbers separated by a comma")
|
|
} else if lat, err = strconv.ParseFloat(splitCoordinates[0], 64); err != nil {
|
|
err = fmt.Errorf("latitude is not a number: %w", err)
|
|
} else if long, err = strconv.ParseFloat(splitCoordinates[1], 64); err != nil {
|
|
err = fmt.Errorf("longitude is not a number: %w", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
func fallbackQuoteContent() *waProto.Message {
|
|
blankString := ""
|
|
return &waProto.Message{
|
|
Conversation: &blankString,
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) convertMatrixMessage(sender *User, evt *event.Event) (*waProto.Message, *User) {
|
|
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
|
|
if !ok {
|
|
portal.log.Debugfln("Failed to handle event %s: unexpected parsed content type %T", evt.ID, evt.Content.Parsed)
|
|
return nil, sender
|
|
}
|
|
|
|
var msg waProto.Message
|
|
var ctxInfo waProto.ContextInfo
|
|
replyToID := content.GetReplyTo()
|
|
if len(replyToID) > 0 {
|
|
content.RemoveReplyFallback()
|
|
replyToMsg := portal.bridge.DB.Message.GetByMXID(replyToID)
|
|
if replyToMsg != nil {
|
|
ctxInfo.StanzaId = &replyToMsg.JID
|
|
ctxInfo.Participant = proto.String(replyToMsg.Sender.String())
|
|
// Using blank content here seems to work fine on all official WhatsApp apps.
|
|
// Getting the content from the phone would be possible, but it's complicated.
|
|
// https://github.com/mautrix/whatsapp/commit/b3312bc663772aa274cea90ffa773da2217bb5e0
|
|
ctxInfo.QuotedMessage = fallbackQuoteContent()
|
|
}
|
|
}
|
|
relaybotFormatted := false
|
|
if sender.NeedsRelaybot(portal) {
|
|
if !portal.HasRelaybot() {
|
|
if sender.HasSession() {
|
|
portal.log.Debugln("Database says", sender.MXID, "not in chat and no relaybot, but trying to send anyway")
|
|
} else {
|
|
portal.log.Debugln("Ignoring message from", sender.MXID, "in chat with no relaybot")
|
|
return nil, sender
|
|
}
|
|
} else {
|
|
relaybotFormatted = portal.addRelaybotFormat(sender, content)
|
|
sender = portal.bridge.Relaybot
|
|
}
|
|
}
|
|
if evt.Type == event.EventSticker {
|
|
content.MsgType = event.MsgImage
|
|
}
|
|
if content.MsgType == event.MsgImage && content.GetInfo().MimeType == "image/gif" {
|
|
content.MsgType = event.MsgVideo
|
|
}
|
|
|
|
switch content.MsgType {
|
|
case event.MsgText, event.MsgEmote, event.MsgNotice:
|
|
text := content.Body
|
|
if content.MsgType == event.MsgNotice && !portal.bridge.Config.Bridge.BridgeNotices {
|
|
return nil, sender
|
|
}
|
|
if content.Format == event.FormatHTML {
|
|
text, ctxInfo.MentionedJid = portal.bridge.Formatter.ParseMatrix(content.FormattedBody)
|
|
}
|
|
if content.MsgType == event.MsgEmote && !relaybotFormatted {
|
|
text = "/me " + text
|
|
}
|
|
if ctxInfo.StanzaId != nil || ctxInfo.MentionedJid != nil {
|
|
msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{
|
|
Text: &text,
|
|
ContextInfo: &ctxInfo,
|
|
}
|
|
} else {
|
|
msg.Conversation = &text
|
|
}
|
|
case event.MsgImage:
|
|
media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaImage)
|
|
if media == nil {
|
|
return nil, sender
|
|
}
|
|
ctxInfo.MentionedJid = media.MentionedJIDs
|
|
msg.ImageMessage = &waProto.ImageMessage{
|
|
ContextInfo: &ctxInfo,
|
|
Caption: &media.Caption,
|
|
JpegThumbnail: media.Thumbnail,
|
|
Url: &media.URL,
|
|
MediaKey: media.MediaKey,
|
|
Mimetype: &content.GetInfo().MimeType,
|
|
FileEncSha256: media.FileEncSHA256,
|
|
FileSha256: media.FileSHA256,
|
|
FileLength: proto.Uint64(uint64(media.FileLength)),
|
|
}
|
|
case event.MsgVideo:
|
|
gifPlayback := content.GetInfo().MimeType == "image/gif"
|
|
media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaVideo)
|
|
if media == nil {
|
|
return nil, sender
|
|
}
|
|
duration := uint32(content.GetInfo().Duration / 1000)
|
|
ctxInfo.MentionedJid = media.MentionedJIDs
|
|
msg.VideoMessage = &waProto.VideoMessage{
|
|
ContextInfo: &ctxInfo,
|
|
Caption: &media.Caption,
|
|
JpegThumbnail: media.Thumbnail,
|
|
Url: &media.URL,
|
|
MediaKey: media.MediaKey,
|
|
Mimetype: &content.GetInfo().MimeType,
|
|
GifPlayback: &gifPlayback,
|
|
Seconds: &duration,
|
|
FileEncSha256: media.FileEncSHA256,
|
|
FileSha256: media.FileSHA256,
|
|
FileLength: proto.Uint64(uint64(media.FileLength)),
|
|
}
|
|
case event.MsgAudio:
|
|
media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaAudio)
|
|
if media == nil {
|
|
return nil, sender
|
|
}
|
|
duration := uint32(content.GetInfo().Duration / 1000)
|
|
msg.AudioMessage = &waProto.AudioMessage{
|
|
ContextInfo: &ctxInfo,
|
|
Url: &media.URL,
|
|
MediaKey: media.MediaKey,
|
|
Mimetype: &content.GetInfo().MimeType,
|
|
Seconds: &duration,
|
|
FileEncSha256: media.FileEncSHA256,
|
|
FileSha256: media.FileSHA256,
|
|
FileLength: proto.Uint64(uint64(media.FileLength)),
|
|
}
|
|
_, isMSC3245Voice := evt.Content.Raw["org.matrix.msc3245.voice"]
|
|
_, isMSC2516Voice := evt.Content.Raw["org.matrix.msc2516.voice"]
|
|
if isMSC3245Voice || isMSC2516Voice {
|
|
msg.AudioMessage.Ptt = proto.Bool(true)
|
|
// hacky hack to add the codecs param that whatsapp seems to require
|
|
msg.AudioMessage.Mimetype = proto.String(addCodecToMime(content.GetInfo().MimeType, "opus"))
|
|
}
|
|
case event.MsgFile:
|
|
media := portal.preprocessMatrixMedia(sender, relaybotFormatted, content, evt.ID, whatsmeow.MediaDocument)
|
|
if media == nil {
|
|
return nil, sender
|
|
}
|
|
msg.DocumentMessage = &waProto.DocumentMessage{
|
|
ContextInfo: &ctxInfo,
|
|
Url: &media.URL,
|
|
Title: &content.Body,
|
|
FileName: &content.Body,
|
|
MediaKey: media.MediaKey,
|
|
Mimetype: &content.GetInfo().MimeType,
|
|
FileEncSha256: media.FileEncSHA256,
|
|
FileSha256: media.FileSHA256,
|
|
FileLength: proto.Uint64(uint64(media.FileLength)),
|
|
}
|
|
case event.MsgLocation:
|
|
lat, long, err := parseGeoURI(content.GeoURI)
|
|
if err != nil {
|
|
portal.log.Debugfln("Invalid geo URI on Matrix event %s: %v", evt.ID, err)
|
|
return nil, sender
|
|
}
|
|
msg.LocationMessage = &waProto.LocationMessage{
|
|
DegreesLatitude: &lat,
|
|
DegreesLongitude: &long,
|
|
Comment: &content.Body,
|
|
ContextInfo: &ctxInfo,
|
|
}
|
|
default:
|
|
portal.log.Debugfln("Unhandled Matrix event %s: unknown msgtype %s", evt.ID, content.MsgType)
|
|
return nil, sender
|
|
}
|
|
return &msg, sender
|
|
}
|
|
|
|
func (portal *Portal) sendErrorMessage(message string, confirmed bool) id.EventID {
|
|
certainty := "may not have been"
|
|
if confirmed {
|
|
certainty = "was not"
|
|
}
|
|
resp, err := portal.sendMainIntentMessage(event.MessageEventContent{
|
|
MsgType: event.MsgNotice,
|
|
Body: fmt.Sprintf("\u26a0 Your message %s bridged: %v", certainty, message),
|
|
})
|
|
if err != nil {
|
|
portal.log.Warnfln("Failed to send bridging error message:", err)
|
|
return ""
|
|
}
|
|
return resp.EventID
|
|
}
|
|
|
|
func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) {
|
|
if portal.bridge.Config.Bridge.DeliveryReceipts {
|
|
err := portal.bridge.Bot.MarkRead(portal.MXID, eventID)
|
|
if err != nil {
|
|
portal.log.Debugfln("Failed to send delivery receipt for %s: %v", eventID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) generateMessageInfo(sender *User) *types.MessageInfo {
|
|
return &types.MessageInfo{
|
|
ID: whatsmeow.GenerateMessageID(),
|
|
Timestamp: time.Now(),
|
|
MessageSource: types.MessageSource{
|
|
Sender: sender.JID,
|
|
Chat: portal.Key.JID,
|
|
IsFromMe: true,
|
|
IsGroup: portal.Key.JID.Server == types.GroupServer || portal.Key.JID.Server == types.BroadcastServer,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) HandleMatrixMessage(sender *User, evt *event.Event) {
|
|
if !portal.HasRelaybot() &&
|
|
((portal.IsPrivateChat() && sender.JID.User != portal.Key.Receiver.User) ||
|
|
portal.sendMatrixConnectionError(sender, evt.ID)) {
|
|
return
|
|
}
|
|
portal.log.Debugfln("Received event %s", evt.ID)
|
|
msg, sender := portal.convertMatrixMessage(sender, evt)
|
|
if msg == nil {
|
|
return
|
|
}
|
|
info := portal.generateMessageInfo(sender)
|
|
dbMsg := portal.markHandled(info, evt.ID, false, true)
|
|
portal.log.Debugln("Sending event", evt.ID, "to WhatsApp", info.ID)
|
|
err := sender.Client.SendMessage(portal.Key.JID, info.ID, msg)
|
|
if err != nil {
|
|
portal.log.Errorln("Error sending message: %v", err)
|
|
portal.sendErrorMessage(err.Error(), true)
|
|
} else {
|
|
portal.log.Debugfln("Handled Matrix event %s", evt.ID)
|
|
portal.sendDeliveryReceipt(evt.ID)
|
|
dbMsg.MarkSent()
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) HandleMatrixRedaction(sender *User, evt *event.Event) {
|
|
if portal.IsPrivateChat() && sender.JID.User != portal.Key.Receiver.User {
|
|
return
|
|
}
|
|
|
|
msg := portal.bridge.DB.Message.GetByMXID(evt.Redacts)
|
|
if msg == nil || msg.Sender.User != sender.JID.User {
|
|
return
|
|
}
|
|
|
|
portal.log.Debugfln("Received redaction event %s", evt.ID)
|
|
info := portal.generateMessageInfo(sender)
|
|
portal.log.Debugln("Sending redaction", evt.ID, "to WhatsApp", info.ID)
|
|
err := sender.Client.SendMessage(portal.Key.JID, info.ID, &waProto.Message{
|
|
ProtocolMessage: &waProto.ProtocolMessage{
|
|
Type: waProto.ProtocolMessage_REVOKE.Enum(),
|
|
Key: &waProto.MessageKey{
|
|
FromMe: proto.Bool(true),
|
|
Id: proto.String(msg.JID),
|
|
RemoteJid: proto.String(portal.Key.JID.String()),
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
portal.log.Errorfln("Error handling Matrix redaction %s: %v", evt.ID, err)
|
|
} else {
|
|
portal.log.Debugln("Handled Matrix redaction %s of %s", evt.ID, evt.Redacts)
|
|
portal.sendDeliveryReceipt(evt.ID)
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) Delete() {
|
|
portal.Portal.Delete()
|
|
portal.bridge.portalsLock.Lock()
|
|
delete(portal.bridge.portalsByJID, portal.Key)
|
|
if len(portal.MXID) > 0 {
|
|
delete(portal.bridge.portalsByMXID, portal.MXID)
|
|
}
|
|
portal.bridge.portalsLock.Unlock()
|
|
}
|
|
|
|
func (portal *Portal) GetMatrixUsers() ([]id.UserID, error) {
|
|
members, err := portal.MainIntent().JoinedMembers(portal.MXID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get member list: %w", err)
|
|
}
|
|
var users []id.UserID
|
|
for userID := range members.Joined {
|
|
_, isPuppet := portal.bridge.ParsePuppetMXID(userID)
|
|
if !isPuppet && userID != portal.bridge.Bot.UserID {
|
|
users = append(users, userID)
|
|
}
|
|
}
|
|
return users, nil
|
|
}
|
|
|
|
func (portal *Portal) CleanupIfEmpty() {
|
|
users, err := portal.GetMatrixUsers()
|
|
if err != nil {
|
|
portal.log.Errorfln("Failed to get Matrix user list to determine if portal needs to be cleaned up: %v", err)
|
|
return
|
|
}
|
|
|
|
if len(users) == 0 {
|
|
portal.log.Infoln("Room seems to be empty, cleaning up...")
|
|
portal.Delete()
|
|
portal.Cleanup(false)
|
|
}
|
|
}
|
|
|
|
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
|
|
} else if portal.bridge.Config.Bridge.BridgeMatrixLeave {
|
|
// TODO should we somehow deduplicate this call if this leave was sent by the bridge?
|
|
// FIXME reimplement
|
|
//resp, err := sender.Client.LeaveGroup(portal.Key.JID)
|
|
//if err != nil {
|
|
// portal.log.Errorfln("Failed to leave group as %s: %v", sender.MXID, err)
|
|
// return
|
|
//}
|
|
//portal.log.Infoln("Leave response:", <-resp)
|
|
}
|
|
portal.CleanupIfEmpty()
|
|
}
|
|
|
|
func (portal *Portal) HandleMatrixKick(sender *User, evt *event.Event) {
|
|
puppet := portal.bridge.GetPuppetByMXID(id.UserID(evt.GetStateKey()))
|
|
if puppet != nil {
|
|
// FIXME reimplement
|
|
//resp, err := sender.Conn.RemoveMember(portal.Key.JID, []string{puppet.JID})
|
|
//if err != nil {
|
|
// portal.log.Errorfln("Failed to kick %s from group as %s: %v", puppet.JID, sender.MXID, err)
|
|
// return
|
|
//}
|
|
//portal.log.Infoln("Kick %s response: %s", puppet.JID, <-resp)
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) HandleMatrixInvite(sender *User, evt *event.Event) {
|
|
puppet := portal.bridge.GetPuppetByMXID(id.UserID(evt.GetStateKey()))
|
|
if puppet != nil {
|
|
// FIXME reimplement
|
|
//resp, err := sender.Conn.AddMember(portal.Key.JID, []string{puppet.JID})
|
|
//if err != nil {
|
|
// portal.log.Errorfln("Failed to add %s to group as %s: %v", puppet.JID, sender.MXID, err)
|
|
// return
|
|
//}
|
|
//portal.log.Infofln("Add %s response: %s", puppet.JID, <-resp)
|
|
}
|
|
}
|
|
|
|
func (portal *Portal) HandleMatrixMeta(sender *User, evt *event.Event) {
|
|
var err error
|
|
switch content := evt.Content.Parsed.(type) {
|
|
case *event.RoomNameEventContent:
|
|
if content.Name == portal.Name {
|
|
return
|
|
}
|
|
// FIXME reimplement
|
|
//portal.Name = content.Name
|
|
//resp, err = sender.Conn.UpdateGroupSubject(content.Name, portal.Key.JID)
|
|
case *event.TopicEventContent:
|
|
if content.Topic == portal.Topic {
|
|
return
|
|
}
|
|
// FIXME reimplement
|
|
//portal.Topic = content.Topic
|
|
//resp, err = sender.Conn.UpdateGroupDescription(sender.JID, portal.Key.JID, content.Topic)
|
|
case *event.RoomAvatarEventContent:
|
|
return
|
|
}
|
|
if err != nil {
|
|
portal.log.Errorln("Failed to update metadata:", err)
|
|
} else {
|
|
//out := <-resp
|
|
//portal.log.Debugln("Successfully updated metadata:", out)
|
|
}
|
|
}
|