mirror of
https://github.com/mautrix/whatsapp.git
synced 2026-05-15 10:16:52 -04:00
303 lines
10 KiB
Go
303 lines
10 KiB
Go
// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge.
|
|
// Copyright (C) 2024 Tulir Asokan
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package connector
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/lib/pq"
|
|
"github.com/rs/zerolog"
|
|
"go.mau.fi/util/dbutil"
|
|
"go.mau.fi/util/random"
|
|
"go.mau.fi/whatsmeow"
|
|
"go.mau.fi/whatsmeow/proto/waCompanionReg"
|
|
"go.mau.fi/whatsmeow/store"
|
|
"go.mau.fi/whatsmeow/store/sqlstore"
|
|
whatsmeowUpgrades "go.mau.fi/whatsmeow/store/sqlstore/upgrades"
|
|
"go.mau.fi/whatsmeow/types"
|
|
waLog "go.mau.fi/whatsmeow/util/log"
|
|
"google.golang.org/protobuf/proto"
|
|
"maunium.net/go/mautrix/bridgev2"
|
|
"maunium.net/go/mautrix/bridgev2/commands"
|
|
"maunium.net/go/mautrix/bridgev2/database"
|
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
|
"maunium.net/go/mautrix/event"
|
|
"maunium.net/go/mautrix/id"
|
|
|
|
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
|
|
"go.mau.fi/mautrix-whatsapp/pkg/msgconv"
|
|
"go.mau.fi/mautrix-whatsapp/pkg/waid"
|
|
)
|
|
|
|
type WhatsAppConnector struct {
|
|
Bridge *bridgev2.Bridge
|
|
Config Config
|
|
DeviceStore *sqlstore.Container
|
|
MsgConv *msgconv.MessageConverter
|
|
DB *wadb.Database
|
|
|
|
firstClientConnectOnce sync.Once
|
|
backgroundConnectOnce sync.Once
|
|
|
|
mediaEditCache MediaEditCache
|
|
mediaEditCacheLock sync.RWMutex
|
|
stopMediaEditCacheLoop atomic.Pointer[context.CancelFunc]
|
|
}
|
|
|
|
func init() {
|
|
sqlstore.PostgresArrayWrapper = pq.Array
|
|
}
|
|
|
|
var (
|
|
_ bridgev2.NetworkConnector = (*WhatsAppConnector)(nil)
|
|
_ bridgev2.MaxFileSizeingNetwork = (*WhatsAppConnector)(nil)
|
|
_ bridgev2.StoppableNetwork = (*WhatsAppConnector)(nil)
|
|
_ bridgev2.NetworkResettingNetwork = (*WhatsAppConnector)(nil)
|
|
)
|
|
|
|
func (wa *WhatsAppConnector) SetMaxFileSize(maxSize int64) {
|
|
wa.MsgConv.MaxFileSize = maxSize
|
|
}
|
|
|
|
func (wa *WhatsAppConnector) GetName() bridgev2.BridgeName {
|
|
return bridgev2.BridgeName{
|
|
DisplayName: "WhatsApp",
|
|
NetworkURL: "https://whatsapp.com",
|
|
NetworkIcon: "mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr",
|
|
NetworkID: "whatsapp",
|
|
BeeperBridgeType: "whatsapp",
|
|
DefaultPort: 29318,
|
|
DefaultCommandPrefix: "!wa",
|
|
}
|
|
}
|
|
|
|
func (wa *WhatsAppConnector) Init(bridge *bridgev2.Bridge) {
|
|
wa.Bridge = bridge
|
|
wa.MsgConv = msgconv.New(bridge)
|
|
wa.MsgConv.AnimatedStickerConfig = wa.Config.AnimatedSticker
|
|
wa.MsgConv.ExtEvPolls = wa.Config.ExtEvPolls
|
|
wa.MsgConv.DisableViewOnce = wa.Config.DisableViewOnce
|
|
wa.MsgConv.OldMediaSuffix = "Requesting old media is not enabled on this bridge."
|
|
wa.MsgConv.FetchURLPreviews = wa.Config.URLPreviews
|
|
if wa.Config.HistorySync.MediaRequests.AutoRequestMedia {
|
|
if wa.Config.HistorySync.MediaRequests.RequestMethod == MediaRequestMethodImmediate {
|
|
wa.MsgConv.OldMediaSuffix = "Media will be requested from your phone automatically soon."
|
|
} else if wa.Config.HistorySync.MediaRequests.RequestMethod == MediaRequestMethodLocalTime {
|
|
wa.MsgConv.OldMediaSuffix = "Media will be requested from your phone automatically overnight."
|
|
}
|
|
}
|
|
wa.DB = wadb.New(bridge.ID, bridge.DB.Database, bridge.Log.With().Str("db_section", "whatsapp").Logger())
|
|
wa.MsgConv.DB = wa.DB
|
|
wa.Bridge.Commands.(*commands.Processor).AddHandlers(
|
|
cmdAccept, cmdSync, cmdInviteLink, cmdResolveLink, cmdJoin,
|
|
)
|
|
wa.mediaEditCache = make(MediaEditCache)
|
|
|
|
whatsmeowDBLog := bridge.Log.With().Str("db_section", "whatsmeow").Logger()
|
|
wa.DeviceStore = sqlstore.NewWithWrappedDB(
|
|
bridge.DB.Child(
|
|
"whatsmeow_version",
|
|
whatsmeowUpgrades.Table,
|
|
dbutil.ZeroLogger(whatsmeowDBLog),
|
|
),
|
|
waLog.Zerolog(whatsmeowDBLog),
|
|
)
|
|
|
|
store.DeviceProps.Os = proto.String(wa.Config.OSName)
|
|
store.DeviceProps.RequireFullSync = proto.Bool(wa.Config.HistorySync.RequestFullSync)
|
|
if fsc := wa.Config.HistorySync.FullSyncConfig; fsc.DaysLimit > 0 && fsc.SizeLimit > 0 && fsc.StorageQuota > 0 {
|
|
if store.DeviceProps.HistorySyncConfig == nil {
|
|
store.DeviceProps.HistorySyncConfig = &waCompanionReg.DeviceProps_HistorySyncConfig{}
|
|
}
|
|
store.DeviceProps.HistorySyncConfig.FullSyncDaysLimit = proto.Uint32(fsc.DaysLimit)
|
|
store.DeviceProps.HistorySyncConfig.FullSyncSizeMbLimit = proto.Uint32(fsc.SizeLimit)
|
|
store.DeviceProps.HistorySyncConfig.StorageQuotaMb = proto.Uint32(fsc.StorageQuota)
|
|
}
|
|
platformID, ok := waCompanionReg.DeviceProps_PlatformType_value[strings.ToUpper(wa.Config.BrowserName)]
|
|
if ok {
|
|
store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_PlatformType(platformID).Enum()
|
|
}
|
|
}
|
|
|
|
func (wa *WhatsAppConnector) Start(ctx context.Context) error {
|
|
err := wa.DeviceStore.Upgrade(ctx)
|
|
if err != nil {
|
|
return bridgev2.DBUpgradeError{Err: err, Section: "whatsmeow"}
|
|
}
|
|
if !wa.Bridge.Background {
|
|
err = wa.DeviceStore.LIDMap.FillCache(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fill LID cache: %w", err)
|
|
}
|
|
}
|
|
err = wa.DB.Upgrade(ctx)
|
|
if err != nil {
|
|
return bridgev2.DBUpgradeError{Err: err, Section: "whatsapp"}
|
|
}
|
|
|
|
if !wa.Bridge.Background && wa.Bridge.DB.KV.Get(ctx, "whatsapp_lid_dms_deleted") == "false" {
|
|
wa.deleteLIDDMsMigration(ctx)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (wa *WhatsAppConnector) deleteLIDDMsMigration(ctx context.Context) {
|
|
log := zerolog.Ctx(ctx).With().Str("action", "delete lid dms").Logger()
|
|
portals, err := wa.Bridge.GetAllPortalsWithMXID(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get portals for LID DM deletion")
|
|
return
|
|
}
|
|
defer wa.Bridge.DB.KV.Set(ctx, "whatsapp_lid_dms_deleted", "true")
|
|
if len(portals) == 0 {
|
|
log.Debug().Msg("No portals found")
|
|
return
|
|
}
|
|
portalsByKey := make(map[networkid.PortalKey]*bridgev2.Portal, len(portals))
|
|
for _, p := range portals {
|
|
if p.Receiver == "" || p.RoomType != database.RoomTypeDM {
|
|
continue
|
|
}
|
|
portalsByKey[p.PortalKey] = p
|
|
}
|
|
_, err = wa.DB.Exec(ctx, "DELETE FROM whatsapp_history_sync_conversation WHERE chat_jid LIKE '%@lid'")
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to remove LID conversations from history sync")
|
|
}
|
|
for key, portal := range portalsByKey {
|
|
parsedID, err := waid.ParsePortalID(key.ID)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("portal_id", string(key.ID)).Msg("Failed to parse portal ID")
|
|
continue
|
|
} else if parsedID.Server != types.HiddenUserServer {
|
|
continue
|
|
}
|
|
var pnStr string
|
|
err = wa.DB.QueryRow(ctx, "SELECT pn FROM whatsmeow_lid_map WHERE lid=$1", parsedID.User).Scan(&pnStr)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("portal_id", string(key.ID)).Msg("Failed to get PN for LID portal")
|
|
continue
|
|
}
|
|
key.ID = waid.MakePortalID(types.JID{User: pnStr, Server: types.DefaultUserServer})
|
|
_, pnPortalExists := portalsByKey[key]
|
|
if !pnPortalExists {
|
|
log.Warn().Str("portal_id", string(key.ID)).Msg("PN portal does not exist, not deleting LID DM")
|
|
continue
|
|
}
|
|
err = portal.Delete(ctx)
|
|
if err != nil {
|
|
log.Err(err).
|
|
Object("portal_key", portal.PortalKey).
|
|
Stringer("portal_mxid", portal.MXID).
|
|
Msg("Failed to delete LID DM portal from database")
|
|
continue
|
|
}
|
|
err = wa.Bridge.Bot.DeleteRoom(ctx, portal.MXID, false)
|
|
if err != nil {
|
|
log.Err(err).
|
|
Object("portal_key", portal.PortalKey).
|
|
Stringer("portal_mxid", portal.MXID).
|
|
Msg("Failed to delete LID DM portal from Matrix")
|
|
continue
|
|
}
|
|
log.Debug().
|
|
Object("portal_key", portal.PortalKey).
|
|
Stringer("portal_mxid", portal.MXID).
|
|
Msg("Deleted LID DM portal")
|
|
}
|
|
log.Info().Msg("Finished deleting LID DM portals")
|
|
}
|
|
|
|
func (wa *WhatsAppConnector) Stop() {
|
|
if stop := wa.stopMediaEditCacheLoop.Swap(nil); stop != nil {
|
|
(*stop)()
|
|
}
|
|
}
|
|
|
|
const kvWAVersion = "whatsapp_web_version"
|
|
|
|
var hardcodedWAVersion = store.GetWAVersion()
|
|
|
|
func (wa *WhatsAppConnector) onFirstBackgroundConnect() {
|
|
verStr := wa.Bridge.DB.KV.Get(wa.Bridge.BackgroundCtx, kvWAVersion)
|
|
if verStr == "" {
|
|
wa.Bridge.Log.Warn().Msg("No WhatsApp web version number cached in database")
|
|
return
|
|
}
|
|
ver, err := store.ParseVersion(verStr)
|
|
if err != nil {
|
|
wa.Bridge.Log.Err(err).Msg("Failed to parse WhatsApp web version number from database")
|
|
return
|
|
}
|
|
wa.Bridge.Log.Debug().
|
|
Stringer("hardcoded_version", hardcodedWAVersion).
|
|
Stringer("cached_version", ver).
|
|
Msg("Using cached WhatsApp web version number")
|
|
store.SetWAVersion(ver)
|
|
}
|
|
|
|
func (wa *WhatsAppConnector) onFirstClientConnect() {
|
|
wa.Bridge.Log.Debug().Msg("Fetching latest WhatsApp web version number")
|
|
ctx := wa.Bridge.BackgroundCtx
|
|
ver, err := whatsmeow.GetLatestVersion(ctx, &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
ResponseHeaderTimeout: 5 * time.Second,
|
|
ForceAttemptHTTP2: true,
|
|
},
|
|
Timeout: 10 * time.Second,
|
|
})
|
|
if err != nil {
|
|
wa.Bridge.Log.Err(err).Msg("Failed to get latest WhatsApp web version number")
|
|
} else {
|
|
wa.Bridge.Log.Debug().
|
|
Stringer("hardcoded_version", hardcodedWAVersion).
|
|
Stringer("latest_version", *ver).
|
|
Msg("Got latest WhatsApp web version number")
|
|
store.SetWAVersion(*ver)
|
|
wa.Bridge.DB.KV.Set(ctx, kvWAVersion, ver.String())
|
|
}
|
|
meclCtx, cancel := context.WithCancel(ctx)
|
|
wa.stopMediaEditCacheLoop.Store(&cancel)
|
|
go wa.mediaEditCacheExpireLoop(meclCtx)
|
|
}
|
|
|
|
func (wa *WhatsAppConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ event.Type) networkid.RawTransactionID {
|
|
// The "proper" way would be a hash of the user ID among other things, but the hash includes random bytes too,
|
|
// so nobody can tell the difference if we just generate random bytes.
|
|
return networkid.RawTransactionID(whatsmeow.WebMessageIDPrefix + strings.ToUpper(hex.EncodeToString(random.Bytes(9))))
|
|
}
|
|
|
|
func (wa *WhatsAppConnector) ResetHTTPTransport() {
|
|
// No-op for now, whatsmeow doesn't use the shared transport config yet
|
|
}
|
|
|
|
func (wa *WhatsAppConnector) ResetNetworkConnections() {
|
|
for _, login := range wa.Bridge.GetAllCachedUserLogins() {
|
|
login.Client.(*WhatsAppClient).Client.ResetConnection()
|
|
}
|
|
}
|