// 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" "encoding/base64" "fmt" "html/template" "strings" "time" "github.com/rs/zerolog" "go.mau.fi/util/exerrors" "go.mau.fi/util/ptr" "go.mau.fi/whatsmeow/proto/waAICommonDeprecated" "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" "google.golang.org/protobuf/proto" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) func (mc *MessageConverter) convertUnknownMessage(ctx context.Context, msg *waE2E.Message) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { data, _ := proto.Marshal(msg) encodedMsg := base64.StdEncoding.EncodeToString(data) extra := make(map[string]any) if len(encodedMsg) < 16*1024 { extra["fi.mau.whatsapp.unsupported_message_data"] = encodedMsg } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, Body: "Unknown message type, please view it on the WhatsApp app", }, Extra: extra, }, nil } var otpContent = format.RenderMarkdown("You received a one-time passcode. For added security, you can only see it on your primary device for WhatsApp. [Learn more](https://faq.whatsapp.com/372839278914311)", true, false) func init() { otpContent.MsgType = event.MsgNotice } func (mc *MessageConverter) convertPlaceholderMessage(ctx context.Context, rawMsg *waE2E.Message) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { if rawMsg.GetPlaceholderMessage().GetType() == waE2E.PlaceholderMessage_MASK_LINKED_DEVICES { return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: ptr.Clone(&otpContent), }, nil } else { return mc.convertUnknownMessage(ctx, rawMsg) } } func (mc *MessageConverter) getHistoryReceiverName(ctx context.Context, receiver string) string { jid, err := types.ParseJID(receiver) if err != nil { zerolog.Ctx(ctx).Err(err).Str("receiver_jid", receiver).Msg("Failed to parse message history receiver JID") return receiver } _, displayname, err := mc.getBasicUserInfo(ctx, jid) if err != nil { zerolog.Ctx(ctx).Err(err).Stringer("receiver_jid", jid).Msg("Failed to resolve message history receiver") if jid.User != "" { return jid.User } return receiver } return displayname } func messageHistoryStartTime(metadata *waE2E.MessageHistoryMetadata, fallback time.Time) time.Time { if metadata == nil { return fallback } if ts := metadata.GetOldestMessageTimestamp(); ts > 0 { return time.Unix(ts, 0).Local() } return fallback } func (mc *MessageConverter) convertMessageHistoryShare(ctx context.Context, info *types.MessageInfo, metadata *waE2E.MessageHistoryMetadata, contextInfo *waE2E.ContextInfo) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { names := make([]string, 0, len(metadata.GetHistoryReceivers())) for _, receiver := range metadata.GetHistoryReceivers() { if name := mc.getHistoryReceiverName(ctx, receiver); name != "" { names = append(names, name) } } receivers := strings.Join(names, ", ") var fallback time.Time if info != nil { fallback = info.Timestamp } startAt := messageHistoryStartTime(metadata, fallback) body := "Message history shared." if !startAt.IsZero() { startTime := startAt.Format("Jan 2, 2006 at 3:04 PM") body = fmt.Sprintf("Message history shared starting on %s.", startTime) switch { case info != nil && info.IsFromMe && receivers != "": body = fmt.Sprintf("You sent %s message history that starts on %s.", receivers, startTime) case receivers != "": body = fmt.Sprintf("Sent %s message history that starts on %s.", receivers, startTime) } } else if info != nil && info.IsFromMe && receivers != "" { body = fmt.Sprintf("You sent %s message history.", receivers) } else if receivers != "" { body = fmt.Sprintf("Sent %s message history.", receivers) } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, Body: body, }, }, contextInfo } const inviteMsg = `%s
This invitation to join "%s" expires at %s. Reply to this message with %s accept to accept the invite.` const inviteMsgBroken = `%s
This invitation to join "%s" expires at %s. However, the invite message is broken or unsupported and cannot be accepted.` const GroupInviteMetaField = "fi.mau.whatsapp.invite" func (mc *MessageConverter) convertGroupInviteMessage(ctx context.Context, info *types.MessageInfo, msg *waE2E.GroupInviteMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { expiry := time.Unix(msg.GetInviteExpiration(), 0) template := inviteMsg var extraAttrs map[string]any var inviteMeta *waid.GroupInviteMeta groupJID, err := types.ParseJID(msg.GetGroupJID()) if err != nil { zerolog.Ctx(ctx).Err(err).Str("invite_group_jid", msg.GetGroupJID()).Msg("Failed to parse invite group JID") template = inviteMsgBroken } else { inviteMeta = &waid.GroupInviteMeta{ JID: groupJID, Code: msg.GetInviteCode(), Expiration: msg.GetInviteExpiration(), Inviter: info.Sender.ToNonAD(), GroupName: msg.GetGroupName(), IsParentGroup: msg.GetGroupType() == waE2E.GroupInviteMessage_PARENT, } extraAttrs = map[string]any{ GroupInviteMetaField: inviteMeta, } } htmlMessage := fmt.Sprintf(template, event.TextToHTML(msg.GetCaption()), msg.GetGroupName(), expiry, mc.Bridge.Config.CommandPrefix) content := &event.MessageEventContent{ MsgType: event.MsgText, Body: format.HTMLToText(htmlMessage), Format: event.FormatHTML, FormattedBody: htmlMessage, } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: content, Extra: extraAttrs, DBMetadata: &waid.MessageMetadata{ GroupInvite: inviteMeta, }, }, msg.GetContextInfo() } func (mc *MessageConverter) convertEphemeralSettingMessage(ctx context.Context, msg *waE2E.ProtocolMessage, ts time.Time, isBackfill bool) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { portal := getPortal(ctx) portalMeta := portal.Metadata.(*waid.PortalMetadata) disappear := database.DisappearingSetting{ Type: event.DisappearingTypeAfterSend, Timer: time.Duration(msg.GetEphemeralExpiration()) * time.Second, } if disappear.Timer == 0 { disappear.Type = "" } dontBridge := portal.Disappear == disappear content := bridgev2.DisappearingMessageNotice(disappear.Timer, false) if !isBackfill { if msg.EphemeralSettingTimestamp == nil || portalMeta.DisappearingTimerSetAt < msg.GetEphemeralSettingTimestamp() { portalMeta.DisappearingTimerSetAt = msg.GetEphemeralSettingTimestamp() portal.UpdateDisappearingSetting(ctx, disappear, bridgev2.UpdateDisappearingSettingOpts{ Sender: getIntent(ctx), Timestamp: ts, Implicit: false, Save: true, SendNotice: false, }) } else { content.Body += ", but the change was ignored." } } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: content, Extra: map[string]any{ "com.beeper.action_message": map[string]any{ "type": "disappearing_timer", "timer": disappear.Timer.Milliseconds(), "timer_type": disappear.Type, "implicit": false, "backfill": isBackfill, }, }, DontBridge: dontBridge, }, nil } const eventMessageTemplate = ` {{- if .Name -}}

{{ .Name }} {{- if .IsCanceled -}} (Canceled){{- end -}}

{{- end -}} {{- if .StartTime -}}

Start time: {{- if .EndTime -}}
End time: {{- end -}}

{{- end -}} {{- if .Location -}}

Location: {{ .Location }}

{{- end -}} {{- if .DescriptionHTML -}}

{{ .DescriptionHTML }}

{{- end -}} {{- if .JoinLink -}}

Join link: {{ .JoinLink }}

{{- end -}} ` var eventMessageTplParsed = exerrors.Must(template.New("eventmessage").Parse(strings.TrimSpace(eventMessageTemplate))) type eventMessageParams struct { Name string IsCanceled bool JoinLink string StartTimeISO string StartTime string EndTimeISO string EndTime string Location string DescriptionHTML template.HTML } func (mc *MessageConverter) convertEventMessage(ctx context.Context, msg *waE2E.EventMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { params := &eventMessageParams{ Name: msg.GetName(), IsCanceled: msg.GetIsCanceled(), JoinLink: msg.GetJoinLink(), Location: msg.GetLocation().GetName(), DescriptionHTML: template.HTML(parseWAFormattingToHTML(msg.GetDescription(), false)), } if msg.StartTime != nil { startTS := time.Unix(msg.GetStartTime(), 0) params.StartTime = startTS.Format(time.RFC1123) params.StartTimeISO = startTS.Format(time.RFC3339) } if msg.EndTime != nil { endTS := time.Unix(msg.GetEndTime(), 0) params.EndTime = endTS.Format(time.RFC1123) params.EndTimeISO = endTS.Format(time.RFC3339) } var buf strings.Builder err := eventMessageTplParsed.Execute(&buf, params) var content event.MessageEventContent if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to execute event message template") content = event.MessageEventContent{ MsgType: event.MsgNotice, Body: "Failed to parse event message", } } else { content = format.HTMLToContent(buf.String()) } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &content, }, msg.GetContextInfo() } func (mc *MessageConverter) convertPinInChatMessage(ctx context.Context, msg *waE2E.PinInChatMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { body := "Pinned a message" if msg.GetType() == waE2E.PinInChatMessage_UNPIN_FOR_ALL { body = "Unpinned a message" } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, Body: body, }, }, nil } func (mc *MessageConverter) convertKeepInChatMessage(ctx context.Context, msg *waE2E.KeepInChatMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { body := "Kept a message" if msg.GetKeepType() == waE2E.KeepType_UNDO_KEEP_FOR_ALL { body = "Unkept a message" } return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgNotice, Body: body, }, }, nil } func (mc *MessageConverter) convertRichResponseMessage(ctx context.Context, msg *waE2E.AIRichResponseMessage) (*bridgev2.ConvertedMessagePart, *waE2E.ContextInfo) { var body strings.Builder // TODO switch to new format? for i, submsg := range msg.GetSubmessages() { if submsg.GetMessageType() == waAICommonDeprecated.AIRichResponseSubMessageType_AI_RICH_RESPONSE_TEXT { if i > 0 { body.WriteString("\n") } body.WriteString(submsg.GetMessageText()) } } content := format.RenderMarkdown(body.String(), true, false) return &bridgev2.ConvertedMessagePart{ Type: event.EventMessage, Content: &content, }, msg.GetContextInfo() }