mirror of
https://github.com/mautrix/whatsapp.git
synced 2026-05-15 10:16:52 -04:00
436 lines
14 KiB
Go
436 lines
14 KiB
Go
package connector
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"go.mau.fi/util/exzerolog"
|
|
"go.mau.fi/util/jsontime"
|
|
"go.mau.fi/util/ptr"
|
|
"go.mau.fi/whatsmeow"
|
|
"go.mau.fi/whatsmeow/types"
|
|
"maunium.net/go/mautrix/bridgev2"
|
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
|
"maunium.net/go/mautrix/bridgev2/simplevent"
|
|
|
|
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
|
|
"go.mau.fi/mautrix-whatsapp/pkg/waid"
|
|
)
|
|
|
|
var ResyncMinInterval = 7 * 24 * time.Hour
|
|
var ResyncLoopInterval = 4 * time.Hour
|
|
var ResyncJitterSeconds = 3600
|
|
|
|
func (wa *WhatsAppClient) EnqueueGhostResync(ghost *bridgev2.Ghost) {
|
|
if ghost.Metadata.(*waid.GhostMetadata).LastSync.Add(ResyncMinInterval).After(time.Now()) {
|
|
return
|
|
}
|
|
wa.resyncQueueLock.Lock()
|
|
jid := waid.ParseUserID(ghost.ID)
|
|
if _, exists := wa.resyncQueue[jid]; !exists {
|
|
wa.resyncQueue[jid] = resyncQueueItem{ghost: ghost}
|
|
nextResyncIn := time.Until(wa.nextResync).String()
|
|
if wa.nextResync.IsZero() {
|
|
nextResyncIn = "never"
|
|
}
|
|
wa.UserLogin.Log.Debug().
|
|
Stringer("jid", jid).
|
|
Str("next_resync_in", nextResyncIn).
|
|
Msg("Enqueued resync for ghost")
|
|
}
|
|
wa.resyncQueueLock.Unlock()
|
|
}
|
|
|
|
func (wa *WhatsAppClient) EnqueuePortalResync(portal *bridgev2.Portal, allowDM bool) {
|
|
jid, _ := waid.ParsePortalID(portal.ID)
|
|
if portal.Metadata.(*waid.PortalMetadata).LastSync.Add(ResyncMinInterval).After(time.Now()) {
|
|
return
|
|
} else if !allowDM && jid.Server != types.GroupServer {
|
|
return
|
|
}
|
|
wa.resyncQueueLock.Lock()
|
|
if _, exists := wa.resyncQueue[jid]; !exists {
|
|
wa.resyncQueue[jid] = resyncQueueItem{portal: portal}
|
|
wa.UserLogin.Log.Debug().
|
|
Stringer("jid", jid).
|
|
Stringer("next_resync_in", time.Until(wa.nextResync)).
|
|
Msg("Enqueued resync for portal")
|
|
}
|
|
wa.resyncQueueLock.Unlock()
|
|
}
|
|
|
|
func (wa *WhatsAppClient) ghostResyncLoop(ctx context.Context) {
|
|
log := wa.UserLogin.Log.With().Str("action", "ghost resync loop").Logger()
|
|
ctx = log.WithContext(ctx)
|
|
wa.nextResync = time.Now().Add(ResyncLoopInterval).Add(-time.Duration(rand.IntN(ResyncJitterSeconds)) * time.Second)
|
|
timer := time.NewTimer(time.Until(wa.nextResync))
|
|
log.Info().Time("first_resync", wa.nextResync).Msg("Ghost resync queue starting")
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
queue := wa.rotateResyncQueue()
|
|
timer.Reset(time.Until(wa.nextResync))
|
|
if len(queue) > 0 {
|
|
wa.doGhostResync(ctx, queue)
|
|
} else {
|
|
log.Trace().Msg("Nothing in background resync queue")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (wa *WhatsAppClient) rotateResyncQueue() map[types.JID]resyncQueueItem {
|
|
wa.resyncQueueLock.Lock()
|
|
defer wa.resyncQueueLock.Unlock()
|
|
wa.nextResync = time.Now().Add(ResyncLoopInterval)
|
|
if len(wa.resyncQueue) == 0 {
|
|
return nil
|
|
}
|
|
queue := wa.resyncQueue
|
|
wa.resyncQueue = make(map[types.JID]resyncQueueItem)
|
|
return queue
|
|
}
|
|
|
|
func (wa *WhatsAppClient) doGhostResync(ctx context.Context, queue map[types.JID]resyncQueueItem) {
|
|
log := zerolog.Ctx(ctx)
|
|
if !wa.IsLoggedIn() {
|
|
log.Warn().Msg("Not logged in, skipping background resyncs")
|
|
return
|
|
}
|
|
log.Debug().Msg("Starting background resyncs")
|
|
defer log.Debug().Msg("Background resyncs finished")
|
|
var ghostJIDs []types.JID
|
|
var ghosts []*bridgev2.Ghost
|
|
var portals []*bridgev2.Portal
|
|
for jid, item := range queue {
|
|
var lastSync time.Time
|
|
if item.ghost != nil {
|
|
lastSync = item.ghost.Metadata.(*waid.GhostMetadata).LastSync.Time
|
|
} else if item.portal != nil {
|
|
lastSync = item.portal.Metadata.(*waid.PortalMetadata).LastSync.Time
|
|
}
|
|
if lastSync.Add(ResyncMinInterval).After(time.Now()) {
|
|
log.Debug().
|
|
Stringer("jid", jid).
|
|
Time("last_sync", lastSync).
|
|
Msg("Not resyncing, last sync was too recent")
|
|
continue
|
|
}
|
|
if item.ghost != nil {
|
|
ghosts = append(ghosts, item.ghost)
|
|
ghostJIDs = append(ghostJIDs, jid)
|
|
} else if item.portal != nil {
|
|
portals = append(portals, item.portal)
|
|
}
|
|
}
|
|
for _, portal := range portals {
|
|
wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{
|
|
EventMeta: simplevent.EventMeta{
|
|
Type: bridgev2.RemoteEventChatResync,
|
|
LogContext: func(c zerolog.Context) zerolog.Context {
|
|
return c.Str("sync_reason", "queue")
|
|
},
|
|
PortalKey: portal.PortalKey,
|
|
},
|
|
GetChatInfoFunc: wa.GetChatInfo,
|
|
})
|
|
}
|
|
if len(ghostJIDs) == 0 {
|
|
return
|
|
}
|
|
log.Debug().Array("jids", exzerolog.ArrayOfStringers(ghostJIDs)).Msg("Doing background sync for users")
|
|
infos, err := wa.Client.GetUserInfo(ctx, ghostJIDs)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get user info for background sync")
|
|
return
|
|
}
|
|
for _, ghost := range ghosts {
|
|
jid := waid.ParseUserID(ghost.ID)
|
|
info, ok := infos[jid]
|
|
if !ok {
|
|
log.Warn().Stringer("jid", jid).Msg("Didn't get info for puppet in background sync")
|
|
continue
|
|
}
|
|
userInfo, err := wa.getUserInfo(ctx, jid, info.PictureID != "" && string(ghost.AvatarID) != info.PictureID)
|
|
if err != nil {
|
|
log.Err(err).Stringer("jid", jid).Msg("Failed to get user info for puppet in background sync")
|
|
continue
|
|
}
|
|
ghost.UpdateInfo(ctx, userInfo)
|
|
wa.syncAltGhostWithInfo(ctx, jid, userInfo)
|
|
}
|
|
}
|
|
|
|
func (wa *WhatsAppClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
|
|
if ghost.Name != "" && ghost.NameSet {
|
|
wa.EnqueueGhostResync(ghost)
|
|
return nil, nil
|
|
}
|
|
jid := waid.ParseUserID(ghost.ID)
|
|
return wa.getUserInfo(ctx, jid, ghost.AvatarID == "")
|
|
}
|
|
|
|
func (wa *WhatsAppClient) getUserInfo(ctx context.Context, jid types.JID, fetchAvatar bool) (*bridgev2.UserInfo, error) {
|
|
contact, err := wa.GetStore().Contacts.GetContact(ctx, jid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return wa.contactToUserInfo(ctx, jid, contact, fetchAvatar), nil
|
|
}
|
|
|
|
func (wa *WhatsAppClient) contactToUserInfo(ctx context.Context, jid types.JID, contact types.ContactInfo, getAvatar bool) *bridgev2.UserInfo {
|
|
if jid == types.MetaAIJID && contact.PushName == jid.User {
|
|
contact.PushName = "Meta AI"
|
|
} else if jid == types.LegacyPSAJID || jid == types.PSAJID {
|
|
contact.PushName = "WhatsApp"
|
|
}
|
|
var altJID types.JID
|
|
if jid.Server == types.DefaultUserServer || jid.Server == types.HiddenUserServer {
|
|
var err error
|
|
altJID, err = wa.GetStore().GetAltJID(ctx, jid)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Stringer("source_jid", jid).Msg("Failed to get alt JID")
|
|
} else if altJID.IsEmpty() {
|
|
zerolog.Ctx(ctx).Debug().Stringer("source_jid", jid).Msg("Alternate JID not found in contactToUserInfo")
|
|
} else {
|
|
extraContact, err := wa.GetStore().Contacts.GetContact(ctx, altJID)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).
|
|
Stringer("source_jid", jid).
|
|
Stringer("alt_jid", altJID).
|
|
Msg("Failed to get contact info from alternate JID")
|
|
} else {
|
|
// Phone contact info should only be stored for phone number JIDs
|
|
if altJID.Server == types.DefaultUserServer {
|
|
if contact.FirstName == "" {
|
|
contact.FirstName = extraContact.FirstName
|
|
}
|
|
if contact.FullName == "" {
|
|
contact.FullName = extraContact.FullName
|
|
}
|
|
}
|
|
if contact.PushName == "" {
|
|
contact.PushName = extraContact.PushName
|
|
}
|
|
if contact.BusinessName == "" {
|
|
contact.BusinessName = extraContact.BusinessName
|
|
}
|
|
if contact.PushName != "" && extraContact.PushName != "" && contact.PushName != extraContact.PushName {
|
|
zerolog.Ctx(ctx).Debug().
|
|
Stringer("source_jid", jid).
|
|
Stringer("alt_jid", altJID).
|
|
Str("source_push_name", contact.PushName).
|
|
Str("alt_push_name", extraContact.PushName).
|
|
Msg("Conflicting push names between JIDs")
|
|
if altJID.Server == types.DefaultUserServer {
|
|
contact.PushName = extraContact.PushName
|
|
}
|
|
}
|
|
if contact.BusinessName != "" && extraContact.BusinessName != "" && contact.BusinessName != extraContact.BusinessName {
|
|
zerolog.Ctx(ctx).Debug().
|
|
Stringer("source_jid", jid).
|
|
Stringer("alt_jid", altJID).
|
|
Str("source_push_name", contact.BusinessName).
|
|
Str("alt_push_name", extraContact.BusinessName).
|
|
Msg("Conflicting business names between JIDs")
|
|
if altJID.Server == types.DefaultUserServer {
|
|
contact.BusinessName = extraContact.BusinessName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var phone string
|
|
if jid.Server == types.DefaultUserServer {
|
|
phone = "+" + jid.User
|
|
} else if altJID.Server == types.DefaultUserServer {
|
|
phone = "+" + altJID.User
|
|
}
|
|
ui := &bridgev2.UserInfo{
|
|
Name: ptr.Ptr(wa.Main.Config.FormatDisplayname(jid, phone, contact)),
|
|
IsBot: ptr.Ptr(jid.IsBot()),
|
|
ExtraUpdates: updateGhostLastSyncAt,
|
|
}
|
|
if jid.Server == types.BotServer {
|
|
ui.Identifiers = []string{}
|
|
} else if phone != "" {
|
|
ui.Identifiers = []string{fmt.Sprintf("tel:%s", phone)}
|
|
}
|
|
if getAvatar {
|
|
ui.ExtraUpdates = bridgev2.MergeExtraUpdaters(ui.ExtraUpdates, wa.fetchGhostAvatar)
|
|
}
|
|
return ui
|
|
}
|
|
|
|
func updateGhostLastSyncAt(_ context.Context, ghost *bridgev2.Ghost) bool {
|
|
meta := ghost.Metadata.(*waid.GhostMetadata)
|
|
forceSave := ResyncMinInterval < 24*time.Hour || time.Since(meta.LastSync.Time) > 24*time.Hour
|
|
meta.LastSync = jsontime.UnixNow()
|
|
return forceSave
|
|
}
|
|
|
|
var expiryRegex = regexp.MustCompile("oe=([0-9A-Fa-f]+)")
|
|
|
|
func avatarInfoToCacheEntry(ctx context.Context, jid types.JID, avatar *types.ProfilePictureInfo) *wadb.AvatarCacheEntry {
|
|
expiry := time.Now().Add(24 * time.Hour)
|
|
match := expiryRegex.FindStringSubmatch(avatar.DirectPath)
|
|
if len(match) == 2 {
|
|
expiryUnix, err := strconv.ParseInt(match[1], 16, 64)
|
|
if err == nil {
|
|
expiry = time.Unix(expiryUnix, 0)
|
|
} else {
|
|
zerolog.Ctx(ctx).Warn().Err(err).
|
|
Strs("match", match).
|
|
Msg("Failed to parse expiry from avatar direct path")
|
|
}
|
|
}
|
|
return &wadb.AvatarCacheEntry{
|
|
EntityJID: jid,
|
|
AvatarID: avatar.ID,
|
|
DirectPath: avatar.DirectPath,
|
|
Expiry: jsontime.U(expiry),
|
|
Gone: false,
|
|
}
|
|
}
|
|
|
|
func (wa *WhatsAppClient) makeDirectMediaAvatar(ctx context.Context, jid types.JID, avatar *types.ProfilePictureInfo, community bool) (*bridgev2.Avatar, error) {
|
|
mxc, err := wa.Main.Bridge.Matrix.GenerateContentURI(ctx, waid.MakeAvatarMediaID(jid, avatar.ID, wa.UserLogin.ID, community))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate MXC URI: %w", err)
|
|
}
|
|
cacheEntry := avatarInfoToCacheEntry(ctx, jid, avatar)
|
|
err = wa.Main.DB.AvatarCache.Put(ctx, cacheEntry)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to cache avatar info: %w", err)
|
|
}
|
|
hash := sha256.Sum256([]byte(avatar.ID))
|
|
if len(avatar.Hash) == 32 {
|
|
hash = [32]byte(avatar.Hash)
|
|
}
|
|
return &bridgev2.Avatar{
|
|
ID: networkid.AvatarID(avatar.ID),
|
|
MXC: mxc,
|
|
Hash: hash,
|
|
}, nil
|
|
}
|
|
|
|
func (wa *WhatsAppClient) fetchGhostAvatar(ctx context.Context, ghost *bridgev2.Ghost) bool {
|
|
jid := waid.ParseUserID(ghost.ID)
|
|
existingID := string(ghost.AvatarID)
|
|
if existingID == "remove" || existingID == "unauthorized" {
|
|
existingID = ""
|
|
}
|
|
var wrappedAvatar *bridgev2.Avatar
|
|
avatar, err := wa.Client.GetProfilePictureInfo(ctx, jid, &whatsmeow.GetProfilePictureParams{ExistingID: existingID})
|
|
if errors.Is(err, whatsmeow.ErrProfilePictureNotSet) {
|
|
wrappedAvatar = &bridgev2.Avatar{
|
|
ID: "remove",
|
|
Remove: true,
|
|
}
|
|
} else if errors.Is(err, whatsmeow.ErrProfilePictureUnauthorized) {
|
|
wrappedAvatar = &bridgev2.Avatar{
|
|
ID: "unauthorized",
|
|
Remove: true,
|
|
}
|
|
} else if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to get avatar info")
|
|
return false
|
|
} else if avatar == nil {
|
|
return false
|
|
} else if wa.Main.MsgConv.DirectMedia {
|
|
wrappedAvatar, err = wa.makeDirectMediaAvatar(ctx, jid, avatar, false)
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to prepare direct media avatar")
|
|
return false
|
|
}
|
|
} else {
|
|
wrappedAvatar = &bridgev2.Avatar{
|
|
ID: networkid.AvatarID(avatar.ID),
|
|
Get: func(ctx context.Context) ([]byte, error) {
|
|
return wa.Client.DownloadMediaWithPath(ctx, avatar.DirectPath, nil, nil, nil, 0, "", "")
|
|
},
|
|
}
|
|
}
|
|
return ghost.UpdateAvatar(ctx, wrappedAvatar)
|
|
}
|
|
|
|
func (wa *WhatsAppClient) resyncContacts(forceAvatarSync, automatic bool) {
|
|
log := wa.UserLogin.Log.With().Str("action", "resync contacts").Logger()
|
|
ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx)
|
|
if automatic && wa.isNewLogin {
|
|
log.Debug().Msg("Waiting for push name history sync before resyncing contacts")
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
|
_ = wa.pushNamesSynced.Wait(timeoutCtx)
|
|
cancel()
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
}
|
|
contactStore := wa.GetStore().Contacts
|
|
contacts, err := contactStore.GetAllContacts(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to get cached contacts")
|
|
return
|
|
}
|
|
log.Info().Int("contact_count", len(contacts)).Msg("Resyncing displaynames with contact info")
|
|
for jid := range contacts {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid))
|
|
if err != nil {
|
|
log.Err(err).Stringer("jid", jid).Msg("Failed to get ghost")
|
|
// Refetch contact info from the store to reduce the risk of races.
|
|
// This should always hit the cache.
|
|
} else if contact, err := contactStore.GetContact(ctx, jid); err != nil {
|
|
log.Err(err).Stringer("jid", jid).Msg("Failed to get contact info")
|
|
} else {
|
|
userInfo := wa.contactToUserInfo(ctx, jid, contact, forceAvatarSync || ghost.AvatarID == "")
|
|
ghost.UpdateInfo(ctx, userInfo)
|
|
wa.syncAltGhostWithInfo(ctx, jid, userInfo)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (wa *WhatsAppClient) syncAltGhostWithInfo(ctx context.Context, jid types.JID, info *bridgev2.UserInfo) {
|
|
log := zerolog.Ctx(ctx)
|
|
var altJID types.JID
|
|
var err error
|
|
if jid.Server == types.HiddenUserServer {
|
|
altJID, err = wa.GetStore().LIDs.GetPNForLID(ctx, jid)
|
|
} else if jid.Server == types.DefaultUserServer {
|
|
altJID, err = wa.GetStore().LIDs.GetLIDForPN(ctx, jid)
|
|
}
|
|
if err != nil {
|
|
log.Warn().Err(err).
|
|
Stringer("jid", jid).
|
|
Msg("Failed to get alternate JID for syncing user info")
|
|
return
|
|
} else if altJID.IsEmpty() {
|
|
return
|
|
}
|
|
ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(altJID))
|
|
if err != nil {
|
|
log.Err(err).
|
|
Stringer("alternate_jid", altJID).
|
|
Stringer("jid", jid).
|
|
Msg("Failed to get ghost for alternate JID")
|
|
return
|
|
}
|
|
ghost.UpdateInfo(ctx, info)
|
|
log.Debug().
|
|
Stringer("jid", jid).
|
|
Stringer("alternate_jid", altJID).
|
|
Msg("Synced alternate ghost with info")
|
|
go wa.syncRemoteProfile(ctx, ghost)
|
|
}
|