2024-09-25 16:01:35 +03:00
// 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"
2024-10-01 16:41:25 +03:00
"encoding/base64"
2024-09-25 16:01:35 +03:00
"fmt"
"html/template"
"strings"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/exerrors"
2024-10-14 19:56:40 +03:00
"go.mau.fi/util/ptr"
2026-03-06 00:00:21 +02:00
"go.mau.fi/whatsmeow/proto/waAICommonDeprecated"
2024-09-25 16:01:35 +03:00
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
2024-10-01 16:41:25 +03:00
"google.golang.org/protobuf/proto"
2024-09-25 16:01:35 +03:00
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
2024-10-15 17:59:57 +03:00
"go.mau.fi/mautrix-whatsapp/pkg/waid"
2024-09-25 16:01:35 +03:00
)
func ( mc * MessageConverter ) convertUnknownMessage ( ctx context . Context , msg * waE2E . Message ) ( * bridgev2 . ConvertedMessagePart , * waE2E . ContextInfo ) {
2024-10-01 16:41:25 +03:00
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
}
2024-09-25 16:01:35 +03:00
return & bridgev2 . ConvertedMessagePart {
Type : event . EventMessage ,
Content : & event . MessageEventContent {
MsgType : event . MsgNotice ,
Body : "Unknown message type, please view it on the WhatsApp app" ,
} ,
2024-10-01 16:41:25 +03:00
Extra : extra ,
2024-09-25 16:01:35 +03:00
} , nil
}
2024-10-14 19:56:40 +03:00
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 )
}
}
2026-03-26 22:02:48 -07:00
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 ( ) {
2026-03-27 09:10:36 -07:00
if name := mc . getHistoryReceiverName ( ctx , receiver ) ; name != "" {
names = append ( names , name )
}
2026-03-26 22:02:48 -07:00
}
2026-03-27 09:10:36 -07:00
receivers := strings . Join ( names , ", " )
2026-03-26 22:02:48 -07:00
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
}
2024-09-25 16:01:35 +03:00
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 {
2025-10-21 17:42:25 +03:00
JID : groupJID ,
Code : msg . GetInviteCode ( ) ,
Expiration : msg . GetInviteExpiration ( ) ,
Inviter : info . Sender . ToNonAD ( ) ,
GroupName : msg . GetGroupName ( ) ,
IsParentGroup : msg . GetGroupType ( ) == waE2E . GroupInviteMessage_PARENT ,
2024-09-25 16:01:35 +03:00
}
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 ( )
}
2025-09-17 15:04:12 +03:00
func ( mc * MessageConverter ) convertEphemeralSettingMessage ( ctx context . Context , msg * waE2E . ProtocolMessage , ts time . Time , isBackfill bool ) ( * bridgev2 . ConvertedMessagePart , * waE2E . ContextInfo ) {
2024-09-25 16:01:35 +03:00
portal := getPortal ( ctx )
portalMeta := portal . Metadata . ( * waid . PortalMetadata )
disappear := database . DisappearingSetting {
2025-08-25 17:26:55 +05:30
Type : event . DisappearingTypeAfterSend ,
2024-09-25 16:01:35 +03:00
Timer : time . Duration ( msg . GetEphemeralExpiration ( ) ) * time . Second ,
}
if disappear . Timer == 0 {
disappear . Type = ""
}
2024-10-08 18:26:32 +03:00
dontBridge := portal . Disappear == disappear
2024-09-25 16:01:35 +03:00
content := bridgev2 . DisappearingMessageNotice ( disappear . Timer , false )
2025-09-17 15:04:12 +03:00
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."
}
2024-09-25 16:01:35 +03:00
}
return & bridgev2 . ConvertedMessagePart {
2025-09-09 16:27:55 +03:00
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 ,
2025-09-17 15:04:12 +03:00
"backfill" : isBackfill ,
2025-09-09 16:27:55 +03:00
} ,
} ,
2024-10-08 18:26:32 +03:00
DontBridge : dontBridge ,
2024-09-25 16:01:35 +03:00
} , nil
}
const eventMessageTemplate = `
{ { - if . Name - } }
2025-09-29 19:23:13 +03:00
< h4 > { { . Name } } { { - if . IsCanceled - } } < span > ( Canceled ) < / span > { { - end - } } < / h4 >
2024-09-25 16:01:35 +03:00
{ { - 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
2025-09-29 19:23:13 +03:00
IsCanceled bool
2024-09-25 16:01:35 +03:00
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 ( ) ,
2025-09-29 19:23:13 +03:00
IsCanceled : msg . GetIsCanceled ( ) ,
2024-09-25 16:01:35 +03:00
JoinLink : msg . GetJoinLink ( ) ,
Location : msg . GetLocation ( ) . GetName ( ) ,
2024-09-25 17:35:46 +03:00
DescriptionHTML : template . HTML ( parseWAFormattingToHTML ( msg . GetDescription ( ) , false ) ) ,
2024-09-25 16:01:35 +03:00
}
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 ( )
}
2025-08-15 19:29:44 +05:30
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
}
2025-08-15 20:00:47 +05:30
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
}
2025-08-15 20:29:57 +05:30
func ( mc * MessageConverter ) convertRichResponseMessage ( ctx context . Context , msg * waE2E . AIRichResponseMessage ) ( * bridgev2 . ConvertedMessagePart , * waE2E . ContextInfo ) {
var body strings . Builder
2026-03-06 00:00:21 +02:00
// TODO switch to new format?
2025-08-15 20:29:57 +05:30
for i , submsg := range msg . GetSubmessages ( ) {
2026-03-06 00:00:21 +02:00
if submsg . GetMessageType ( ) == waAICommonDeprecated . AIRichResponseSubMessageType_AI_RICH_RESPONSE_TEXT {
2025-08-15 20:29:57 +05:30
if i > 0 {
body . WriteString ( "\n" )
}
body . WriteString ( submsg . GetMessageText ( ) )
}
}
2025-08-15 20:49:41 +05:30
content := format . RenderMarkdown ( body . String ( ) , true , false )
2025-08-15 20:29:57 +05:30
return & bridgev2 . ConvertedMessagePart {
2025-08-15 20:49:41 +05:30
Type : event . EventMessage ,
Content : & content ,
2025-08-15 20:29:57 +05:30
} , msg . GetContextInfo ( )
}