1
0
Fork 0
mirror of https://github.com/mautrix/whatsapp.git synced 2026-05-15 10:16:52 -04:00
mautrix-whatsapp/pkg/connector/connector.go
2026-01-15 14:52:36 +02:00

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