// 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" "fmt" "strconv" "strings" "time" "github.com/rs/zerolog" "go.mau.fi/util/ptr" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/appstate" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/bridgev2/simplevent" "maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/event" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) const ( WANotLoggedIn status.BridgeStateErrorCode = "wa-not-logged-in" WALoggedOut status.BridgeStateErrorCode = "wa-logged-out" WAMainDeviceGone status.BridgeStateErrorCode = "wa-main-device-gone" WAUnknownLogout status.BridgeStateErrorCode = "wa-unknown-logout" WANotConnected status.BridgeStateErrorCode = "wa-not-connected" WAConnecting status.BridgeStateErrorCode = "wa-connecting" WAKeepaliveTimeout status.BridgeStateErrorCode = "wa-keepalive-timeout" WAPhoneOffline status.BridgeStateErrorCode = "wa-phone-offline" WAConnectionFailed status.BridgeStateErrorCode = "wa-connection-failed" WADisconnected status.BridgeStateErrorCode = "wa-transient-disconnect" WAStreamReplaced status.BridgeStateErrorCode = "wa-stream-replaced" WAStreamError status.BridgeStateErrorCode = "wa-stream-error" WAClientOutdated status.BridgeStateErrorCode = "wa-client-outdated" WATemporaryBan status.BridgeStateErrorCode = "wa-temporary-ban" ) func init() { status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{ WALoggedOut: "You were logged out from another device. Relogin to continue using the bridge.", WANotLoggedIn: "You're not logged into WhatsApp. Relogin to continue using the bridge.", WAMainDeviceGone: "Your phone was logged out from WhatsApp. Relogin to continue using the bridge.", WAUnknownLogout: "You were logged out for an unknown reason. Relogin to continue using the bridge.", WANotConnected: "You're not connected to WhatsApp", WAConnecting: "Reconnecting to WhatsApp...", WAKeepaliveTimeout: "The WhatsApp web servers are not responding. The bridge will try to reconnect.", WAPhoneOffline: "Your phone hasn't been seen in over 12 days. The bridge is currently connected, but will get disconnected if you don't open the app soon.", WAConnectionFailed: "Connecting to the WhatsApp web servers failed.", WADisconnected: "Disconnected from WhatsApp. Trying to reconnect.", WAClientOutdated: "Connect failure: 405 client outdated. Bridge must be updated.", WAStreamReplaced: "Stream replaced: the bridge was started in another location.", }) } func (wa *WhatsAppClient) handleWAEvent(rawEvt any) (success bool) { log := wa.UserLogin.Log ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx) success = true switch evt := rawEvt.(type) { case *events.Message: success = wa.handleWAMessage(ctx, evt) case *events.Receipt: success = wa.handleWAReceipt(ctx, evt) case *events.ChatPresence: wa.handleWAChatPresence(ctx, evt) case *events.UndecryptableMessage: success = wa.handleWAUndecryptableMessage(ctx, evt) case *events.CallOffer: success = wa.handleWACallStart(ctx, evt.GroupJID, evt.CallCreator, evt.CallCreatorAlt, evt.CallID, "", evt.Timestamp) case *events.CallOfferNotice: success = wa.handleWACallStart(ctx, evt.GroupJID, evt.CallCreator, evt.CallCreatorAlt, evt.CallID, evt.Type, evt.Timestamp) case *events.CallTerminate, *events.CallRelayLatency, *events.CallAccept, *events.UnknownCallEvent: // ignore case *events.IdentityChange: wa.handleWAIdentityChange(ctx, evt) case *events.MarkChatAsRead: success = wa.handleWAMarkChatAsRead(ctx, evt) case *events.DeleteForMe: success = wa.handleWADeleteForMe(ctx, evt) case *events.DeleteChat: success = wa.handleWADeleteChat(ctx, evt) case *events.Mute: success = wa.handleWAMute(evt) case *events.Archive: success = wa.handleWAArchive(evt) case *events.Pin: success = wa.handleWAPin(evt) case *events.HistorySync: wa.UserLogin.Log.Warn().Msg("Unexpected history sync event received") case *events.MediaRetry: wa.phoneSeen(evt.Timestamp) success = wa.UserLogin.QueueRemoteEvent(&WAMediaRetry{MediaRetry: evt, wa: wa}).Success case *events.GroupInfo: success = wa.handleWAGroupInfoChange(ctx, evt) case *events.JoinedGroup: success = wa.handleWAJoinedGroup(ctx, evt) case *events.NewsletterJoin: success = wa.handleWANewsletterJoin(ctx, evt) case *events.NewsletterLeave: success = wa.handleWANewsletterLeave(evt) case *events.Picture: success = wa.handleWAPictureUpdate(ctx, evt) case *events.AppStateSyncComplete: wa.handleWAAppStateSyncComplete(ctx, evt) case *events.AppStateSyncError: wa.handleWAAppStateSyncError(ctx, evt) case *events.AppState: // Intentionally ignored case *events.PushNameSetting: // Send presence available when connecting and when the pushname is changed. // This makes sure that outgoing messages always have the right pushname. err := wa.updatePresence(ctx, types.PresenceUnavailable) if err != nil { log.Warn().Err(err).Msg("Failed to send presence after push name update") } _, _, err = wa.GetStore().Contacts.PutPushName(ctx, wa.JID.ToNonAD(), evt.Action.GetName()) if err != nil { log.Err(err).Msg("Failed to update push name in store") } _, _, err = wa.GetStore().Contacts.PutPushName(ctx, wa.GetStore().GetLID().ToNonAD(), evt.Action.GetName()) if err != nil { log.Err(err).Msg("Failed to update push name in store") } go wa.syncGhost(wa.JID.ToNonAD(), "push name setting", nil) case *events.Contact: go wa.syncGhost(evt.JID, "contact event", nil) case *events.PushName: go wa.syncGhost(evt.JID, "push name event", nil) case *events.BusinessName: go wa.syncGhost(evt.JID, "business name event", nil) case *events.Connected: log.Debug().Msg("Connected to WhatsApp socket") wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) if len(wa.GetStore().PushName) > 0 { go func() { err := wa.updatePresence(ctx, types.PresenceUnavailable) if err != nil { log.Warn().Err(err).Msg("Failed to send initial presence after connecting") } }() go wa.syncRemoteProfile(ctx, nil) } wa.MC.OnConnect(store.GetWAVersion()[2], wa.Device.Platform) case *events.OfflineSyncPreview: log.Info(). Int("message_count", evt.Messages). Int("receipt_count", evt.Receipts). Int("notification_count", evt.Notifications). Int("app_data_change_count", evt.AppDataChanges). Msg("Server sent number of events that were missed during downtime") case *events.OfflineSyncCompleted: if !wa.PhoneRecentlySeen(true) { log.Info(). Int("evt_count", evt.Count). Time("phone_last_seen", wa.UserLogin.Metadata.(*waid.UserLoginMetadata).PhoneLastSeen.Time). Msg("Offline sync completed, but phone last seen date is still old") } else { log.Info(). Int("evt_count", evt.Count). Msg("Offline sync completed") } wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) wa.notifyOfflineSyncWaiter(nil) case *events.LoggedOut: wa.handleWALogout(evt.Reason, evt.OnConnect) wa.notifyOfflineSyncWaiter(fmt.Errorf("logged out: %s", evt.Reason)) case *events.Disconnected: // Don't send the normal transient disconnect state if we're already in a different transient disconnect state. // TODO remove this if/when the phone offline state is moved to a sub-state of CONNECTED if wa.UserLogin.BridgeState.GetPrev().Error != WAPhoneOffline && wa.PhoneRecentlySeen(false) { wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WADisconnected}) } wa.notifyOfflineSyncWaiter(fmt.Errorf("disconnected")) case *events.StreamError: var message string if evt.Code != "" { message = fmt.Sprintf("Unknown stream error with code %s", evt.Code) } else if children := evt.Raw.GetChildren(); len(children) > 0 { message = fmt.Sprintf("Unknown stream error (contains %s node)", children[0].Tag) } else { message = "Unknown stream error" } wa.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateUnknownError, Error: WAStreamError, Message: message, }) wa.notifyOfflineSyncWaiter(fmt.Errorf("stream error: %s", message)) case *events.StreamReplaced: wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: WAStreamReplaced}) wa.notifyOfflineSyncWaiter(fmt.Errorf("stream replaced")) case *events.KeepAliveTimeout: wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateTransientDisconnect, Error: WAKeepaliveTimeout}) case *events.KeepAliveRestored: log.Info().Msg("Keepalive restored after timeouts, sending connected event") wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) case *events.ConnectFailure: wa.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateUnknownError, Error: status.BridgeStateErrorCode(fmt.Sprintf("wa-connect-failure-%d", evt.Reason)), Message: fmt.Sprintf("Unknown connection failure: %s (%s)", evt.Reason, evt.Message), }) wa.notifyOfflineSyncWaiter(fmt.Errorf("connection failure: %s (%s)", evt.Reason, evt.Message)) case *events.ClientOutdated: wa.UserLogin.Log.Error().Msg("Got a client outdated connect failure. The bridge is likely out of date, please update immediately.") wa.UserLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: WAClientOutdated}) wa.notifyOfflineSyncWaiter(fmt.Errorf("client outdated")) case *events.TemporaryBan: wa.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: WATemporaryBan, Message: evt.String(), }) wa.notifyOfflineSyncWaiter(fmt.Errorf("temporary ban: %s", evt.String())) default: log.Debug().Type("event_type", rawEvt).Msg("Unhandled WhatsApp event") } return } func (wa *WhatsAppClient) rerouteWAMessage(ctx context.Context, evtType string, info *types.MessageSource, msgID any) { if (info.Chat.Server == types.HiddenUserServer || info.Chat.Server == types.BroadcastServer) && info.Sender.Server == types.HiddenUserServer && info.SenderAlt.IsEmpty() { info.SenderAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, info.Sender) } if info.Chat.Server == types.HiddenUserServer && info.IsFromMe && info.RecipientAlt.IsEmpty() { info.RecipientAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, info.Chat) } if info.Chat.Server == types.HiddenUserServer && info.Sender.ToNonAD() == info.Chat && info.SenderAlt.Server == types.DefaultUserServer { wa.UserLogin.Log.Debug(). Stringer("lid", info.Sender). Stringer("pn", info.SenderAlt). Any("message_id", msgID). Str("evt_type", evtType). Msg("Forced LID DM sender to phone number in incoming message") info.Sender, info.SenderAlt = info.SenderAlt, info.Sender info.Chat = info.Sender.ToNonAD() } else if info.Chat.Server == types.HiddenUserServer && info.IsFromMe && info.RecipientAlt.Server == types.DefaultUserServer { wa.UserLogin.Log.Debug(). Stringer("lid", info.Chat). Stringer("pn", info.RecipientAlt). Any("message_id", msgID). Str("evt_type", evtType). Msg("Forced LID DM sender to phone number in own message sent from another device") info.Chat = info.RecipientAlt.ToNonAD() if info.Sender.Server == types.HiddenUserServer { info.Sender, info.SenderAlt = info.SenderAlt, info.Sender if info.Sender.IsEmpty() { info.Sender = wa.GetStore().GetJID() info.Sender.Device = info.SenderAlt.Device } } } else if info.Chat.Server == types.BroadcastServer && info.Sender.Server == types.HiddenUserServer && info.SenderAlt.Server == types.DefaultUserServer { wa.UserLogin.Log.Debug(). Stringer("lid", info.Sender). Stringer("pn", info.SenderAlt). Stringer("chat", info.Chat). Any("message_id", msgID). Str("evt_type", evtType). Msg("Forced LID broadcast list sender to phone number in incoming message") info.Sender, info.SenderAlt = info.SenderAlt, info.Sender } else if info.Sender.Server == types.BotServer && info.Chat.Server == types.HiddenUserServer { chatPN, err := wa.GetStore().LIDs.GetPNForLID(ctx, info.Chat) if err != nil { wa.UserLogin.Log.Err(err). Any("message_id", msgID). Stringer("lid", info.Chat). Str("evt_type", evtType). Msg("Failed to get phone number of DM for incoming bot message") } else if !chatPN.IsEmpty() { wa.UserLogin.Log.Debug(). Stringer("lid", info.Chat). Stringer("pn", chatPN). Any("message_id", msgID). Str("evt_type", evtType). Msg("Forced LID chat to phone number in bot message") info.Chat = chatPN } } } func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Message) (success bool) { success = true wa.rerouteWAMessage(ctx, "message", &evt.Info.MessageSource, evt.Info.ID) wa.UserLogin.Log.Trace(). Any("info", evt.Info). Any("payload", evt.Message). Msg("Received WhatsApp message") if evt.Info.Chat == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast { return } if evt.Info.IsFromMe && evt.Message.GetProtocolMessage().GetHistorySyncNotification() != nil && wa.Main.Bridge.Config.Backfill.Enabled && wa.Client.ManualHistorySyncDownload { wa.saveWAHistorySyncNotification(ctx, evt.Message.ProtocolMessage.HistorySyncNotification) } messageAssoc := evt.Message.GetMessageContextInfo().GetMessageAssociation() if assocType := messageAssoc.GetAssociationType(); assocType == waE2E.MessageAssociation_HD_IMAGE_DUAL_UPLOAD || assocType == waE2E.MessageAssociation_HD_VIDEO_DUAL_UPLOAD { parentKey := messageAssoc.GetParentMessageKey() associatedMessage := evt.Message.GetAssociatedChildMessage().GetMessage() wa.UserLogin.Log.Debug(). Str("message_id", evt.Info.ID). Str("parent_id", parentKey.GetID()). Stringer("assoc_type", assocType). Msg("Received HD replacement message, converting to edit") protocolMsg := &waE2E.ProtocolMessage{ Type: waE2E.ProtocolMessage_MESSAGE_EDIT.Enum(), Key: parentKey, EditedMessage: associatedMessage, } evt.Message = &waE2E.Message{ ProtocolMessage: protocolMsg, } } else if assocType == waE2E.MessageAssociation_MOTION_PHOTO { //evt.Message = evt.Message.GetAssociatedChildMessage().GetMessage() wa.UserLogin.Log.Debug(). Str("message_id", evt.Info.ID). Str("parent_id", messageAssoc.GetParentMessageKey().GetID()). Msg("Ignoring motion photo update") return } parsedMessageType := getMessageType(evt.Message) if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") { return } if encReact := evt.Message.GetEncReactionMessage(); encReact != nil { decrypted, err := wa.Client.DecryptReaction(ctx, evt) if err != nil { wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt reaction") return } decrypted.Key = encReact.GetTargetMessageKey() evt.Message.ReactionMessage = decrypted } if encComment := evt.Message.GetEncCommentMessage(); encComment != nil { decrypted, err := wa.Client.DecryptComment(ctx, evt) if err != nil { wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt comment") } else { decrypted.EncCommentMessage = evt.Message.GetEncCommentMessage() evt.Message = decrypted } } if encMessage := evt.Message.GetSecretEncryptedMessage(); encMessage != nil { decrypted, err := wa.Client.DecryptSecretEncryptedMessage(ctx, evt) if err != nil { wa.UserLogin.Log.Err(err).Str("message_id", evt.Info.ID).Msg("Failed to decrypt message") return } evt.RawMessage = decrypted evt.UnwrapRaw() parsedMessageType = getMessageType(evt.Message) } res := wa.UserLogin.QueueRemoteEvent(&WAMessageEvent{ MessageInfoWrapper: &MessageInfoWrapper{ Info: evt.Info, wa: wa, }, Message: evt.Message, MsgEvent: evt, parsedMessageType: parsedMessageType, }) return res.Success } func (wa *WhatsAppClient) handleWAUndecryptableMessage(ctx context.Context, evt *events.UndecryptableMessage) bool { wa.rerouteWAMessage(ctx, "undecryptable message", &evt.Info.MessageSource, evt.Info.ID) wa.UserLogin.Log.Debug(). Any("info", evt.Info). Bool("unavailable", evt.IsUnavailable). Str("decrypt_fail", string(evt.DecryptFailMode)). Msg("Received undecryptable WhatsApp message") wa.trackUndecryptable(evt) if evt.DecryptFailMode == events.DecryptFailHide { return true } if evt.Info.Chat == types.StatusBroadcastJID && !wa.Main.Config.EnableStatusBroadcast { return true } res := wa.UserLogin.QueueRemoteEvent(&WAUndecryptableMessage{ MessageInfoWrapper: &MessageInfoWrapper{ Info: evt.Info, wa: wa, }, Type: evt.UnavailableType, }) return res.Success } func (wa *WhatsAppClient) handleWAReceipt(ctx context.Context, evt *events.Receipt) (success bool) { origChat := evt.Chat wa.rerouteWAMessage(ctx, "receipt", &evt.MessageSource, evt.MessageIDs) if evt.IsFromMe && evt.Sender.Device == 0 { wa.phoneSeen(evt.Timestamp) } var evtType bridgev2.RemoteEventType switch evt.Type { case types.ReceiptTypeRead, types.ReceiptTypeReadSelf: evtType = bridgev2.RemoteEventReadReceipt case types.ReceiptTypeDelivered: evtType = bridgev2.RemoteEventDeliveryReceipt case types.ReceiptTypeSender: fallthrough default: return true } targets := make([]networkid.MessageID, len(evt.MessageIDs)) messageSender := wa.JID if !evt.MessageSender.IsEmpty() { messageSender = evt.MessageSender // Second part of rerouting receipts in LID chats if messageSender == origChat && evt.Chat != origChat { messageSender = evt.Chat } } else if evt.Chat.Server == types.GroupServer && evt.Sender.Server == types.HiddenUserServer { lid := wa.GetStore().GetLID() if !lid.IsEmpty() { messageSender = lid } } for i, id := range evt.MessageIDs { targets[i] = waid.MakeMessageID(evt.Chat, messageSender, id) } res := wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{ EventMeta: simplevent.EventMeta{ Type: evtType, PortalKey: wa.makeWAPortalKey(evt.Chat), Sender: wa.makeEventSender(ctx, evt.Sender), Timestamp: evt.Timestamp, }, Targets: targets, }) return res.Success } func (wa *WhatsAppClient) handleWAChatPresence(ctx context.Context, evt *events.ChatPresence) { if evt.Chat.Server == types.HiddenUserServer && evt.Sender.ToNonAD() == evt.Chat { if evt.SenderAlt.IsEmpty() { evt.SenderAlt, _ = wa.GetStore().LIDs.GetPNForLID(ctx, evt.Sender) } if evt.SenderAlt.Server == types.DefaultUserServer { evt.Sender, evt.SenderAlt = evt.SenderAlt, evt.Sender evt.Chat = evt.Sender.ToNonAD() } } typingType := bridgev2.TypingTypeText timeout := 15 * time.Second if evt.Media == types.ChatPresenceMediaAudio { typingType = bridgev2.TypingTypeRecordingMedia } if evt.State == types.ChatPresencePaused { timeout = 0 } wa.UserLogin.QueueRemoteEvent(&simplevent.Typing{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventTyping, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.Chat), Sender: wa.makeEventSender(ctx, evt.Sender), Timestamp: time.Now(), }, Timeout: timeout, Type: typingType, }) } func (wa *WhatsAppClient) handleWALogout(reason events.ConnectFailureReason, onConnect bool) { errorCode := WAUnknownLogout if reason == events.ConnectFailureLoggedOut { errorCode = WALoggedOut } else if reason == events.ConnectFailureMainDeviceGone { errorCode = WAMainDeviceGone } wa.Disconnect() wa.Client = nil wa.JID = types.EmptyJID wa.UserLogin.Metadata.(*waid.UserLoginMetadata).WADeviceID = 0 wa.UserLogin.BridgeState.Send(status.BridgeState{ StateEvent: status.StateBadCredentials, Error: errorCode, }) } const callEventMaxAge = 15 * time.Minute func (wa *WhatsAppClient) handleWACallStart(ctx context.Context, group, sender, senderAlt types.JID, id, callType string, ts time.Time) bool { if !wa.Main.Config.CallStartNotices || time.Since(ts) > callEventMaxAge { return true } if sender.Server == types.HiddenUserServer && senderAlt.Server == types.DefaultUserServer { wa.UserLogin.Log.Debug(). Stringer("lid", sender). Stringer("pn", senderAlt). Str("call_id", id). Msg("Forced LID caller to phone number in incoming call") sender, senderAlt = senderAlt, sender } chat := group if chat.IsEmpty() { chat = sender } return wa.UserLogin.QueueRemoteEvent(&simplevent.Message[string]{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventMessage, LogContext: nil, PortalKey: wa.makeWAPortalKey(chat), Sender: wa.makeEventSender(ctx, sender), CreatePortal: true, Timestamp: ts, StreamOrder: ts.Unix(), }, Data: callType, ID: waid.MakeFakeMessageID(chat, sender, "call-"+id), ConvertMessageFunc: convertCallStart, }).Success } func convertCallStart(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, callType string) (*bridgev2.ConvertedMessage, error) { text := "Incoming call. Use the WhatsApp app to answer." if callType != "" { text = fmt.Sprintf("Incoming %s call. Use the WhatsApp app to answer.", callType) } return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgText, Body: text, BeeperActionMessage: &event.BeeperActionMessage{ Type: event.BeeperActionMessageCall, CallType: event.BeeperActionMessageCallType(callType), }, }, }}, }, nil } func (wa *WhatsAppClient) handleWAIdentityChange(ctx context.Context, evt *events.IdentityChange) { if !wa.Main.Config.IdentityChangeNotices { return } wa.UserLogin.QueueRemoteEvent(&simplevent.Message[*events.IdentityChange]{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventMessage, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.JID), Sender: wa.makeEventSender(ctx, evt.JID), CreatePortal: false, Timestamp: evt.Timestamp, }, Data: evt, ID: waid.MakeFakeMessageID(evt.JID, evt.JID, "idchange-"+strconv.FormatInt(evt.Timestamp.UnixMilli(), 10)), ConvertMessageFunc: convertIdentityChange, }) } func convertIdentityChange(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data *events.IdentityChange) (*bridgev2.ConvertedMessage, error) { ghost, err := portal.Bridge.GetGhostByID(ctx, waid.MakeUserID(data.JID)) if err != nil { return nil, err } text := fmt.Sprintf("Your security code with %s changed.", ghost.Name) if data.Implicit { text = fmt.Sprintf("Your security code with %s (device #%d) changed.", ghost.Name, data.JID.Device) } return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, Body: text, }, }}, }, nil } func (wa *WhatsAppClient) handleWADeleteChat(ctx context.Context, evt *events.DeleteChat) bool { chatJID := wa.maybeConvertJIDToLID(ctx, evt.JID) return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatDelete, PortalKey: wa.makeWAPortalKey(chatJID), Timestamp: evt.Timestamp, }, OnlyForMe: true, Children: true, }).Success } func (wa *WhatsAppClient) handleWADeleteForMe(ctx context.Context, evt *events.DeleteForMe) bool { chatJID := wa.maybeConvertJIDToLID(ctx, evt.ChatJID) return wa.UserLogin.QueueRemoteEvent(&simplevent.MessageRemove{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventMessageRemove, PortalKey: wa.makeWAPortalKey(chatJID), Timestamp: evt.Timestamp, }, TargetMessage: waid.MakeMessageID(chatJID, evt.SenderJID, evt.MessageID), OnlyForMe: true, }).Success } func (wa *WhatsAppClient) handleWAMarkChatAsRead(ctx context.Context, evt *events.MarkChatAsRead) bool { chatJID := wa.maybeConvertJIDToLID(ctx, evt.JID) return wa.UserLogin.QueueRemoteEvent(&simplevent.Receipt{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventReadReceipt, PortalKey: wa.makeWAPortalKey(chatJID), Sender: wa.makeEventSender(ctx, wa.JID), Timestamp: evt.Timestamp, }, ReadUpTo: evt.Timestamp, }).Success } func (wa *WhatsAppClient) syncGhost(jid types.JID, reason string, pictureID *string) { log := wa.UserLogin.Log.With(). Str("action", "sync ghost"). Str("reason", reason). Str("picture_id", ptr.Val(pictureID)). Stringer("jid", jid). Logger() ctx := log.WithContext(wa.Main.Bridge.BackgroundCtx) ghost, err := wa.Main.Bridge.GetGhostByID(ctx, waid.MakeUserID(jid)) if err != nil { log.Err(err).Msg("Failed to get ghost") return } if pictureID != nil && *pictureID != "" && ghost.AvatarID == networkid.AvatarID(*pictureID) { return } userInfo, err := wa.getUserInfo(ctx, jid, pictureID != nil) if err != nil { log.Err(err).Msg("Failed to get user info") } else { ghost.UpdateInfo(ctx, userInfo) log.Debug().Msg("Synced ghost info") wa.syncAltGhostWithInfo(ctx, jid, userInfo) } go wa.syncRemoteProfile(ctx, ghost) } func (wa *WhatsAppClient) handleWAPictureUpdate(ctx context.Context, evt *events.Picture) bool { if evt.JID.Server == types.DefaultUserServer || evt.JID.Server == types.HiddenUserServer || evt.JID.Server == types.BotServer { go wa.syncGhost(evt.JID, "picture event", &evt.PictureID) return true } else { var changes bridgev2.ChatInfo if evt.Remove { changes.Avatar = &bridgev2.Avatar{Remove: true, ID: "remove"} } else { changes.ExtraUpdates = wa.makePortalAvatarFetcher(evt.PictureID, evt.Author, evt.Timestamp) } return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatInfoChange, LogContext: func(c zerolog.Context) zerolog.Context { return c. Str("wa_event_type", "picture"). Stringer("picture_author", evt.Author). Str("new_picture_id", evt.PictureID). Bool("remove_picture", evt.Remove) }, PortalKey: wa.makeWAPortalKey(evt.JID), Sender: wa.makeEventSender(ctx, evt.Author), Timestamp: evt.Timestamp, }, ChatInfoChange: &bridgev2.ChatInfoChange{ ChatInfo: &changes, }, }).Success } } func (wa *WhatsAppClient) handleWAGroupInfoChange(ctx context.Context, evt *events.GroupInfo) bool { eventMeta := simplevent.EventMeta{ Type: bridgev2.RemoteEventChatInfoChange, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.JID), CreatePortal: true, Timestamp: evt.Timestamp, } if evt.Sender != nil { eventMeta.Sender = wa.makeEventSender(ctx, *evt.Sender) } if evt.Delete != nil { eventMeta.Type = bridgev2.RemoteEventChatDelete return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{EventMeta: eventMeta}).Success } else { return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ EventMeta: eventMeta, ChatInfoChange: wa.wrapGroupInfoChange(ctx, evt), }).Success } } func (wa *WhatsAppClient) handleWAJoinedGroup(ctx context.Context, evt *events.JoinedGroup) bool { if wa.createDedup.Pop(evt.CreateKey) { return true } return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.JID), CreatePortal: true, }, ChatInfo: wa.wrapGroupInfo(ctx, &evt.GroupInfo), }).Success } func (wa *WhatsAppClient) handleWANewsletterJoin(ctx context.Context, evt *events.NewsletterJoin) bool { return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.ID), CreatePortal: true, }, ChatInfo: wa.wrapNewsletterInfo(ctx, &evt.NewsletterMetadata), }).Success } func (wa *WhatsAppClient) handleWANewsletterLeave(evt *events.NewsletterLeave) bool { return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatDelete, LogContext: nil, PortalKey: wa.makeWAPortalKey(evt.ID), }, OnlyForMe: true, }).Success } func (wa *WhatsAppClient) handleWAUserLocalPortalInfo(chatJID types.JID, ts time.Time, info *bridgev2.UserLocalPortalInfo) bool { return wa.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatInfoChange, PortalKey: wa.makeWAPortalKey(chatJID), Timestamp: ts, }, ChatInfoChange: &bridgev2.ChatInfoChange{ ChatInfo: &bridgev2.ChatInfo{ UserLocal: info, }, }, }).Success } func (wa *WhatsAppClient) handleWAMute(evt *events.Mute) bool { var mutedUntil time.Time if evt.Action.GetMuted() { mutedUntil = event.MutedForever if evt.Action.GetMuteEndTimestamp() > 0 { mutedUntil = time.Unix(evt.Action.GetMuteEndTimestamp(), 0) } } else { mutedUntil = bridgev2.Unmuted } return wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ MutedUntil: &mutedUntil, }) } func (wa *WhatsAppClient) handleWAArchive(evt *events.Archive) bool { var tag event.RoomTag if evt.Action.GetArchived() { tag = wa.Main.Config.ArchiveTag } return wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ Tag: &tag, }) } func (wa *WhatsAppClient) handleWAPin(evt *events.Pin) bool { var tag event.RoomTag if evt.Action.GetPinned() { tag = wa.Main.Config.PinnedTag } return wa.handleWAUserLocalPortalInfo(evt.JID, evt.Timestamp, &bridgev2.UserLocalPortalInfo{ Tag: &tag, }) } func (wa *WhatsAppClient) handleWAAppStateSyncComplete(ctx context.Context, evt *events.AppStateSyncComplete) { log := zerolog.Ctx(ctx).With(). Str("patch_name", string(evt.Name)). Uint64("patch_version", evt.Version). Logger() if len(wa.GetStore().PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock { err := wa.updatePresence(ctx, types.PresenceUnavailable) if err != nil { log.Warn().Err(err).Msg("Failed to send presence after app state sync") } go wa.syncRemoteProfile(log.WithContext(context.Background()), nil) } else if evt.Name == appstate.WAPatchCriticalUnblockLow { go wa.resyncContacts(false, true) } wa.appStateRecoveryLock.Lock() defer wa.appStateRecoveryLock.Unlock() meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata) if ts, exists := meta.AppStateRecoveryAttempted[evt.Name]; exists { delete(wa.appStateFullSyncAttempted, evt.Name) delete(meta.AppStateRecoveryAttempted, evt.Name) err := wa.UserLogin.Save(ctx) if err != nil { log.Err(err).Msg("Failed to save login metadata after unmarking app state recovery as attempted") } else { log.Info(). Time("recovery_ts", ts). Msg("Unmarked app state recovery as attempted after successful full sync") } } else if ts, exists = wa.appStateFullSyncAttempted[evt.Name]; exists { delete(wa.appStateFullSyncAttempted, evt.Name) log.Debug().Time("full_sync_ts", ts).Msg("Unmarked app state full sync attempted after successful sync") } } func (wa *WhatsAppClient) handleWAAppStateSyncError(ctx context.Context, evt *events.AppStateSyncError) { log := zerolog.Ctx(ctx).With(). Str("patch_name", string(evt.Name)). Logger() wa.appStateRecoveryLock.Lock() defer wa.appStateRecoveryLock.Unlock() meta := wa.UserLogin.Metadata.(*waid.UserLoginMetadata) lastRecovery := meta.AppStateRecoveryAttempted[evt.Name] lastFullSync := wa.appStateFullSyncAttempted[evt.Name] if !lastRecovery.IsZero() && time.Since(lastRecovery) < 48*time.Hour { log.Debug().Err(evt.Error). Time("last_recovery_attempt", lastRecovery). Time("last_full_sync_attempt", lastFullSync). Msg("App state sync failed, but recovery already attempted") return } if !evt.FullSync { if !lastFullSync.IsZero() { log.Debug(). Err(evt.Error). Time("last_full_sync_attempt", lastFullSync). Msg("App state sync failed, but full sync already attempted") return } wa.appStateFullSyncAttempted[evt.Name] = time.Now() log.Info(). Err(evt.Error). Msg("Trying full sync for app state after partial sync error") go func() { err := wa.Client.FetchAppState(ctx, evt.Name, true, false) if err != nil { log.Err(err).Msg("Full app state sync failed") } else { log.Debug().Msg("Full app state sync succeeded") } }() return } log.Info(). Err(evt.Error). Msg("Trying recovery for app state after full sync error") if meta.AppStateRecoveryAttempted == nil { meta.AppStateRecoveryAttempted = make(map[appstate.WAPatchName]time.Time) } meta.AppStateRecoveryAttempted[evt.Name] = time.Now() err := wa.UserLogin.Save(ctx) if err != nil { log.Err(err).Msg("Failed to save login metadata after marking app state recovery as attempted") } go func() { resp, err := wa.Client.SendPeerMessage(ctx, whatsmeow.BuildAppStateRecoveryRequest(evt.Name)) if err != nil { log.Err(err).Msg("Failed to send app state recovery request") } else { log.Debug(). Str("message_id", resp.ID). Time("message_ts", resp.Timestamp). Msg("Sent app state recovery request") } }() }