// 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 = `%sThis 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 -}}