1
0
Fork 0
mirror of https://github.com/mautrix/whatsapp.git synced 2026-05-15 02:06:53 -04:00
mautrix-whatsapp/pkg/msgconv/wa-misc.go
Kishan Bagaria 5a7217e0ff -
2026-03-27 09:10:36 -07:00

350 lines
12 KiB
Go

// 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 <https://www.gnu.org/licenses/>.
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<hr/>This invitation to join "%s" expires at %s. Reply to this message with <code>%s accept</code> to accept the invite.`
const inviteMsgBroken = `%s<hr/>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 -}}
<h4>{{ .Name }} {{- if .IsCanceled -}}<span> (Canceled)</span>{{- end -}}</h4>
{{- end -}}
{{- if .StartTime -}}
<p>
Start time: <time datetime="{{ .StartTimeISO }}">{{ .StartTime }}</time>
{{- if .EndTime -}}
<br>
End time: <time datetime="{{ .EndTimeISO }}">{{ .EndTime }}</time>
{{- end -}}
</p>
{{- end -}}
{{- if .Location -}}
<p>Location: {{ .Location }}</p>
{{- end -}}
{{- if .DescriptionHTML -}}
<p>{{ .DescriptionHTML }}</p>
{{- end -}}
{{- if .JoinLink -}}
<p>Join link: <a href="{{ .JoinLink }}">{{ .JoinLink }}</a></p>
{{- 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()
}