// 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 msgconv import ( "context" "fmt" "html" _ "image/gif" _ "image/jpeg" _ "image/png" "strings" "time" "github.com/rs/zerolog" "go.mau.fi/util/ptr" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" _ "golang.org/x/image/webp" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) type contextKey int const ( contextKeyClient contextKey = iota contextKeyIntent contextKeyPortal ContextKeyEditTargetID ) func getClient(ctx context.Context) *whatsmeow.Client { return ctx.Value(contextKeyClient).(*whatsmeow.Client) } func getIntent(ctx context.Context) bridgev2.MatrixAPI { return ctx.Value(contextKeyIntent).(bridgev2.MatrixAPI) } func getPortal(ctx context.Context) *bridgev2.Portal { return ctx.Value(contextKeyPortal).(*bridgev2.Portal) } func getEditTargetID(ctx context.Context) types.MessageID { editID, _ := ctx.Value(ContextKeyEditTargetID).(types.MessageID) return editID } func (mc *MessageConverter) getBasicUserInfo(ctx context.Context, user types.JID) (id.UserID, string, error) { ghost, err := mc.Bridge.GetGhostByID(ctx, waid.MakeUserID(user)) if err != nil { return "", "", fmt.Errorf("failed to get ghost by ID: %w", err) } var pnJID types.JID if user.Server == types.DefaultUserServer { pnJID = user } else if user.Server == types.HiddenUserServer { cli := getClient(ctx) if user.User == cli.Store.GetLID().User { pnJID = cli.Store.GetJID() } else { pnJID, err = cli.Store.LIDs.GetPNForLID(ctx, user) if err != nil { zerolog.Ctx(ctx).Err(err). Stringer("lid", user). Msg("Failed to get PN for LID in mention bridging") } } } if !pnJID.IsEmpty() { portal := getPortal(ctx) login := mc.Bridge.GetCachedUserLoginByID(waid.MakeUserLoginID(pnJID)) if login != nil && (portal.Receiver == "" || portal.Receiver == login.ID) { return login.UserMXID, ghost.Name, nil } } return ghost.Intent.GetMXID(), ghost.Name, nil } func (mc *MessageConverter) addMentions(ctx context.Context, mentionedJID []string, into *event.MessageEventContent) { if len(mentionedJID) == 0 { return } into.EnsureHasHTML() for _, jid := range mentionedJID { parsed, err := types.ParseJID(jid) if err != nil { zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to parse mentioned JID") continue } mxid, displayname, err := mc.getBasicUserInfo(ctx, parsed) if err != nil { zerolog.Ctx(ctx).Err(err).Str("jid", jid).Msg("Failed to get user info") continue } into.Mentions.UserIDs = append(into.Mentions.UserIDs, mxid) mentionText := "@" + parsed.User into.Body = strings.ReplaceAll(into.Body, mentionText, displayname) into.FormattedBody = strings.ReplaceAll(into.FormattedBody, mentionText, fmt.Sprintf(`%s`, mxid.URI().MatrixToURL(), html.EscapeString(displayname))) } } var failedCommentPart = &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ Body: "Failed to decrypt comment", MsgType: event.MsgNotice, }, } func (mc *MessageConverter) ToMatrix( ctx context.Context, portal *bridgev2.Portal, client *whatsmeow.Client, intent bridgev2.MatrixAPI, waMsg *waE2E.Message, rawWaMsg *waE2E.Message, info *types.MessageInfo, isViewOnce bool, isBackfill bool, previouslyConvertedPart *bridgev2.ConvertedMessagePart, ) *bridgev2.ConvertedMessage { if waMsg == nil { waMsg = &waE2E.Message{} } ctx = context.WithValue(ctx, contextKeyClient, client) ctx = context.WithValue(ctx, contextKeyIntent, intent) ctx = context.WithValue(ctx, contextKeyPortal, portal) var part *bridgev2.ConvertedMessagePart var contextInfo *waE2E.ContextInfo switch { case waMsg.Conversation != nil, waMsg.ExtendedTextMessage != nil: part, contextInfo = mc.convertTextMessage(ctx, waMsg) case waMsg.TemplateMessage != nil: part, contextInfo = mc.convertTemplateMessage(ctx, info, waMsg.TemplateMessage) case waMsg.ButtonsMessage != nil: part, contextInfo = mc.convertButtonsMessage(ctx, info, waMsg.ButtonsMessage) case waMsg.ButtonsResponseMessage != nil: part, contextInfo = mc.convertButtonsResponseMessage(ctx, waMsg.ButtonsResponseMessage) case waMsg.InteractiveMessage != nil: part, contextInfo = mc.convertInteractiveMessage(ctx, info, waMsg.InteractiveMessage) case waMsg.InteractiveResponseMessage != nil: part, contextInfo = mc.convertInteractiveResponseMessage(ctx, waMsg.InteractiveResponseMessage) case waMsg.HighlyStructuredMessage != nil: part, contextInfo = mc.convertTemplateMessage(ctx, info, waMsg.HighlyStructuredMessage.GetHydratedHsm()) case waMsg.TemplateButtonReplyMessage != nil: part, contextInfo = mc.convertTemplateButtonReplyMessage(ctx, waMsg.TemplateButtonReplyMessage) case waMsg.ListMessage != nil: part, contextInfo = mc.convertListMessage(ctx, waMsg.ListMessage) case waMsg.ListResponseMessage != nil: part, contextInfo = mc.convertListResponseMessage(ctx, waMsg.ListResponseMessage) case waMsg.PollCreationMessage != nil: part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessage) case waMsg.PollCreationMessageV2 != nil: part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessageV2) case waMsg.PollCreationMessageV3 != nil: part, contextInfo = mc.convertPollCreationMessage(ctx, waMsg.PollCreationMessageV3) case waMsg.PollUpdateMessage != nil: part, contextInfo = mc.convertPollUpdateMessage(ctx, info, waMsg.PollUpdateMessage) case waMsg.EventMessage != nil: part, contextInfo = mc.convertEventMessage(ctx, waMsg.EventMessage) case waMsg.PinInChatMessage != nil: part, contextInfo = mc.convertPinInChatMessage(ctx, waMsg.PinInChatMessage) case waMsg.KeepInChatMessage != nil: part, contextInfo = mc.convertKeepInChatMessage(ctx, waMsg.KeepInChatMessage) case waMsg.RichResponseMessage != nil: part, contextInfo = mc.convertRichResponseMessage(ctx, waMsg.RichResponseMessage) case waMsg.ImageMessage != nil: part, contextInfo = mc.convertMediaMessage(ctx, waMsg.ImageMessage, "photo", info, isViewOnce, previouslyConvertedPart) case waMsg.StickerMessage != nil: part, contextInfo = mc.convertMediaMessage(ctx, waMsg.StickerMessage, "sticker", info, isViewOnce, previouslyConvertedPart) case waMsg.VideoMessage != nil: part, contextInfo = mc.convertMediaMessage(ctx, waMsg.VideoMessage, "video attachment", info, isViewOnce, previouslyConvertedPart) case waMsg.PtvMessage != nil: part, contextInfo = mc.convertMediaMessage(ctx, waMsg.PtvMessage, "video message", info, isViewOnce, previouslyConvertedPart) case waMsg.AudioMessage != nil: typeName := "audio attachment" if waMsg.AudioMessage.GetPTT() { typeName = "voice message" } part, contextInfo = mc.convertMediaMessage(ctx, waMsg.AudioMessage, typeName, info, isViewOnce, previouslyConvertedPart) case waMsg.DocumentMessage != nil: part, contextInfo = mc.convertMediaMessage(ctx, waMsg.DocumentMessage, "file attachment", info, isViewOnce, previouslyConvertedPart) case waMsg.AlbumMessage != nil: part, contextInfo = mc.convertAlbumMessage(ctx, waMsg.AlbumMessage) case waMsg.LocationMessage != nil: part, contextInfo = mc.convertLocationMessage(ctx, waMsg.LocationMessage) case waMsg.LiveLocationMessage != nil: part, contextInfo = mc.convertLiveLocationMessage(ctx, waMsg.LiveLocationMessage) case waMsg.ContactMessage != nil: part, contextInfo = mc.convertContactMessage(ctx, waMsg.ContactMessage) case waMsg.ContactsArrayMessage != nil: part, contextInfo = mc.convertContactsArrayMessage(ctx, waMsg.ContactsArrayMessage) case waMsg.PlaceholderMessage != nil: part, contextInfo = mc.convertPlaceholderMessage(ctx, waMsg) case waMsg.GroupInviteMessage != nil: part, contextInfo = mc.convertGroupInviteMessage(ctx, info, waMsg.GroupInviteMessage) case waMsg.ProtocolMessage != nil && waMsg.ProtocolMessage.GetType() == waE2E.ProtocolMessage_EPHEMERAL_SETTING: part, contextInfo = mc.convertEphemeralSettingMessage(ctx, waMsg.ProtocolMessage, info.Timestamp, isBackfill) case waMsg.MessageHistoryBundle != nil: part, contextInfo = mc.convertMessageHistoryShare(ctx, info, waMsg.MessageHistoryBundle.GetMessageHistoryMetadata(), waMsg.MessageHistoryBundle.GetContextInfo()) case waMsg.MessageHistoryNotice != nil: part, contextInfo = mc.convertMessageHistoryShare(ctx, info, waMsg.MessageHistoryNotice.GetMessageHistoryMetadata(), waMsg.MessageHistoryNotice.GetContextInfo()) case waMsg.EncCommentMessage != nil: part = failedCommentPart default: part, contextInfo = mc.convertUnknownMessage(ctx, rawWaMsg) } part.Content.Mentions = &event.Mentions{} if part.DBMetadata == nil { part.DBMetadata = &waid.MessageMetadata{} } dbMeta := part.DBMetadata.(*waid.MessageMetadata) dbMeta.SenderDeviceID = info.Sender.Device if info.IsIncomingBroadcast() { dbMeta.BroadcastListJID = &info.Chat if part.Extra == nil { part.Extra = map[string]any{} } part.Extra["fi.mau.whatsapp.source_broadcast_list"] = info.Chat.String() } mc.addMentions(ctx, contextInfo.GetMentionedJID(), part.Content) if contextInfo.GetNonJIDMentions() == 1 { part.Content.Mentions.Room = true } cm := &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{part}, } if contextInfo.GetExpiration() > 0 { cm.Disappear.Timer = time.Duration(contextInfo.GetExpiration()) * time.Second cm.Disappear.Type = event.DisappearingTypeAfterSend } if portal.Disappear.Timer != cm.Disappear.Timer && portal.Metadata.(*waid.PortalMetadata).DisappearingTimerSetAt < contextInfo.GetEphemeralSettingTimestamp() { portal.UpdateDisappearingSetting(ctx, cm.Disappear, bridgev2.UpdateDisappearingSettingOpts{ Sender: intent, Timestamp: info.Timestamp, Implicit: true, Save: true, SendNotice: true, }) } if contextInfo.GetStanzaID() != "" { pcp, _ := types.ParseJID(contextInfo.GetParticipant()) chat, _ := types.ParseJID(contextInfo.GetRemoteJID()) if chat.IsEmpty() { chat, _ = waid.ParsePortalID(portal.ID) } // We reroute all DMs to the phone number JID, so reroute reply participants too pcp = rerouteMessageKey(ctx, chat, pcp, getPortal(ctx).Metadata.(*waid.PortalMetadata).AddressingMode == types.AddressingModeLID) if store := getClient(ctx).Store; store != nil && chat.Server == types.DefaultUserServer && pcp.Server == types.HiddenUserServer { pcpPN, _ := store.LIDs.GetPNForLID(ctx, pcp) zerolog.Ctx(ctx).Debug(). Stringer("orig_participant", pcp). Stringer("rerouted_participant", pcpPN). Msg("Rerouting reply target (PN recipient in LID DM)") if !pcpPN.IsEmpty() { pcp = pcpPN } } else if store != nil && chat.Server == types.GroupServer && pcp.Server == types.DefaultUserServer && getPortal(ctx).Metadata.(*waid.PortalMetadata).AddressingMode == types.AddressingModeLID { pcpLID, _ := store.LIDs.GetLIDForPN(ctx, pcp) zerolog.Ctx(ctx).Debug(). Stringer("orig_participant", pcp). Stringer("rerouted_participant", pcpLID). Msg("Rerouting reply target (PN recipient in LID group)") if !pcpLID.IsEmpty() { pcp = pcpLID } } cm.ReplyTo = &networkid.MessageOptionalPartID{ MessageID: waid.MakeMessageID(chat, pcp, contextInfo.GetStanzaID()), } } if contextInfo.GetIsForwarded() { hasCaption := part.Content.FileName != "" && part.Content.FileName != part.Content.Body isMedia := part.Content.MsgType.IsMedia() isText := part.Content.MsgType.IsText() if isMedia && !hasCaption { part.Content.FileName = part.Content.Body part.Content.Body = "↷ Forwarded" part.Content.Format = event.FormatHTML part.Content.FormattedBody = "

↷ Forwarded

" } else if isText || isMedia { part.Content.EnsureHasHTML() part.Content.Body = "↷ Forwarded\n\n" + part.Content.Body part.Content.FormattedBody = "

↷ Forwarded

" + part.Content.FormattedBody } } commentTarget := waMsg.GetEncCommentMessage().GetTargetMessageKey() if commentTarget == nil { commentTarget = waMsg.GetCommentMessage().GetTargetMessageKey() } if commentTarget != nil { pcp, _ := types.ParseJID(commentTarget.GetParticipant()) chat, _ := types.ParseJID(commentTarget.GetRemoteJID()) if chat.IsEmpty() { chat, _ = waid.ParsePortalID(portal.ID) } cm.ThreadRoot = ptr.Ptr(waid.MakeMessageID(chat, pcp, commentTarget.GetID())) } return cm }