// 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 . 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() } }