2024-11-06 13:14:12 +01: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 connector
import (
"context"
"encoding/json"
"errors"
"fmt"
2025-05-26 23:51:46 +08:00
"io"
2024-11-06 13:14:12 +01:00
"net/http"
"os"
"sync"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/exsync"
2025-11-26 19:13:48 +02:00
"go.mau.fi/util/ptr"
2024-11-06 13:14:12 +01:00
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waMmsRetry"
"go.mau.fi/whatsmeow/types/events"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/mediaproxy"
2025-07-29 16:18:56 +03:00
"go.mau.fi/mautrix-whatsapp/pkg/connector/wadb"
2024-11-06 13:14:12 +01:00
"go.mau.fi/mautrix-whatsapp/pkg/msgconv"
"go.mau.fi/mautrix-whatsapp/pkg/waid"
)
var _ bridgev2 . DirectMediableNetwork = ( * WhatsAppConnector ) ( nil )
func ( wa * WhatsAppConnector ) SetUseDirectMedia ( ) {
wa . MsgConv . DirectMedia = true
}
var ErrReloadNeeded = mautrix . RespError {
ErrCode : "FI.MAU.WHATSAPP_RELOAD_NEEDED" ,
Err : "Media is no longer available on WhatsApp servers and must be re-requested from your phone" ,
StatusCode : http . StatusNotFound ,
}
func ( wa * WhatsAppConnector ) Download ( ctx context . Context , mediaID networkid . MediaID , params map [ string ] string ) ( mediaproxy . GetMediaResponse , error ) {
2025-07-29 16:18:56 +03:00
parsedID , err := waid . ParseMediaID ( mediaID )
2024-11-06 13:14:12 +01:00
if err != nil {
return nil , err
}
2025-07-29 16:18:56 +03:00
log := zerolog . Ctx ( ctx ) . With ( ) . Any ( "parsed_media_id" , parsedID ) . Logger ( )
2024-11-06 16:48:42 +01:00
ctx = log . WithContext ( ctx )
2025-07-29 16:18:56 +03:00
if parsedID . Message != nil {
return wa . downloadMessageDirectMedia ( ctx , parsedID , params )
} else if parsedID . Avatar != nil {
return wa . downloadAvatarDirectMedia ( ctx , parsedID , params )
} else {
return nil , fmt . Errorf ( "unexpected media ID parsing result" )
}
}
func ( wa * WhatsAppConnector ) downloadAvatarDirectMedia ( ctx context . Context , parsedID * waid . ParsedMediaID , params map [ string ] string ) ( mediaproxy . GetMediaResponse , error ) {
ul := wa . Bridge . GetCachedUserLoginByID ( parsedID . UserLogin )
if ul == nil {
return nil , fmt . Errorf ( "%w: user login %s not found" , bridgev2 . ErrNotLoggedIn , parsedID . UserLogin )
}
waClient := ul . Client . ( * WhatsAppClient )
if waClient . Client == nil {
return nil , fmt . Errorf ( "no WhatsApp client found on login %s" , parsedID . UserLogin )
}
cachedInfo , err := wa . DB . AvatarCache . Get ( ctx , parsedID . Avatar . TargetJID , parsedID . Avatar . AvatarID )
if err != nil {
return nil , fmt . Errorf ( "failed to get avatar cache entry: %w" , err )
}
if cachedInfo != nil && cachedInfo . Gone {
2025-11-26 19:13:48 +02:00
return nil , mautrix . MNotFound . WithMessage ( "Avatar is no longer available (cached response)" )
2025-07-29 16:18:56 +03:00
} else if cachedInfo == nil || cachedInfo . Expiry . Time . Before ( time . Now ( ) . Add ( 5 * time . Minute ) ) {
zerolog . Ctx ( ctx ) . Debug ( ) .
Str ( "avatar_id" , parsedID . Avatar . AvatarID ) .
Msg ( "Refreshing avatar URL from WhatsApp servers" )
2025-10-27 16:20:47 +02:00
avatar , err := waClient . Client . GetProfilePictureInfo ( ctx , parsedID . Avatar . TargetJID , & whatsmeow . GetProfilePictureParams {
2025-07-29 16:18:56 +03:00
IsCommunity : parsedID . Avatar . Community ,
} )
if errors . Is ( err , whatsmeow . ErrProfilePictureNotSet ) ||
errors . Is ( err , whatsmeow . ErrProfilePictureUnauthorized ) ||
( err == nil && ( avatar == nil || avatar . ID != parsedID . Avatar . AvatarID ) ) {
2025-11-26 19:13:48 +02:00
zerolog . Ctx ( ctx ) . Debug ( ) .
Err ( err ) .
Stringer ( "target_jid" , parsedID . Avatar . TargetJID ) .
Bool ( "is_community" , parsedID . Avatar . Community ) .
Str ( "wanted_avatar_id" , parsedID . Avatar . AvatarID ) .
Str ( "got_avatar_id" , ptr . Val ( avatar ) . ID ) .
Msg ( "Avatar is no longer available" )
2025-07-29 16:18:56 +03:00
err = wa . DB . AvatarCache . Put ( ctx , & wadb . AvatarCacheEntry {
EntityJID : parsedID . Avatar . TargetJID ,
AvatarID : parsedID . Avatar . AvatarID ,
Gone : true ,
} )
if err != nil {
zerolog . Ctx ( ctx ) . Warn ( ) . Err ( err ) .
Str ( "avatar_id" , parsedID . Avatar . AvatarID ) .
Msg ( "Failed to mark avatar as gone in cache" )
}
return nil , mautrix . MNotFound . WithMessage ( "Avatar is no longer available" )
} else if err != nil {
2026-01-29 15:25:56 +00:00
return nil , mautrix . MUnknown . WithMessage ( "failed to refresh avatar url: %w" , err ) . WithCanRetry ( true )
2025-07-29 16:18:56 +03:00
}
cachedInfo = avatarInfoToCacheEntry ( ctx , parsedID . Avatar . TargetJID , avatar )
err = wa . DB . AvatarCache . Put ( ctx , cachedInfo )
if err != nil {
zerolog . Ctx ( ctx ) . Warn ( ) . Err ( err ) .
Str ( "avatar_id" , avatar . ID ) .
Msg ( "Failed to update avatar cache entry" )
}
}
return & mediaproxy . GetMediaResponseFile {
2025-12-03 22:10:57 +02:00
Callback : func ( w * os . File ) ( * mediaproxy . FileMeta , error ) {
return & mediaproxy . FileMeta { } , waClient . Client . DownloadMediaWithPathToFile (
2025-07-29 16:18:56 +03:00
ctx , cachedInfo . DirectPath , nil , nil , nil , 0 , "" , "" , w ,
)
} ,
} , nil
}
func ( wa * WhatsAppConnector ) downloadMessageDirectMedia ( ctx context . Context , parsedID * waid . ParsedMediaID , params map [ string ] string ) ( mediaproxy . GetMediaResponse , error ) {
log := zerolog . Ctx ( ctx )
msg , err := wa . Bridge . DB . Message . GetFirstPartByID ( ctx , parsedID . UserLogin , parsedID . Message . String ( ) )
2024-11-06 13:14:12 +01:00
if err != nil {
return nil , fmt . Errorf ( "failed to get message: %w" , err )
} else if msg == nil {
return nil , fmt . Errorf ( "message not found" )
}
dmm := msg . Metadata . ( * waid . MessageMetadata ) . DirectMediaMeta
if dmm == nil {
return nil , fmt . Errorf ( "message does not have direct media metadata" )
}
var keys * msgconv . FailedMediaKeys
err = json . Unmarshal ( dmm , & keys )
if err != nil {
return nil , fmt . Errorf ( "failed to unmarshal media keys: %w" , err )
}
var ul * bridgev2 . UserLogin
2025-07-29 16:18:56 +03:00
if parsedID . UserLogin != "" {
ul = wa . Bridge . GetCachedUserLoginByID ( parsedID . UserLogin )
2024-11-06 13:14:12 +01:00
} else {
logins , err := wa . Bridge . GetUserLoginsInPortal ( ctx , msg . Room )
if err != nil {
return nil , fmt . Errorf ( "failed to get user logins in portal: %w" , err )
}
for _ , login := range logins {
if login . Client . IsLoggedIn ( ) {
ul = login
break
}
}
}
if ul == nil || ! ul . Client . IsLoggedIn ( ) {
2026-01-09 19:00:11 +00:00
return nil , bridgev2 . ErrNotLoggedIn
2024-11-06 13:14:12 +01:00
}
waClient := ul . Client . ( * WhatsAppClient )
if waClient . Client == nil {
return nil , fmt . Errorf ( "no WhatsApp client found on login" )
}
return & mediaproxy . GetMediaResponseFile {
2025-12-03 22:10:57 +02:00
Callback : func ( f * os . File ) ( * mediaproxy . FileMeta , error ) {
2025-05-14 14:40:03 +03:00
err := waClient . Client . DownloadToFile ( ctx , keys , f )
2025-10-31 12:03:12 +00:00
if errors . Is ( err , whatsmeow . ErrMediaDownloadFailedWith403 ) || errors . Is ( err , whatsmeow . ErrMediaDownloadFailedWith404 ) || errors . Is ( err , whatsmeow . ErrMediaDownloadFailedWith410 ) || errors . Is ( err , whatsmeow . ErrNoURLPresent ) {
2024-11-06 16:48:42 +01:00
val := params [ "fi.mau.whatsapp.reload_media" ]
if val == "false" || ( ! wa . Config . DirectMediaAutoRequest && val != "true" ) {
2025-12-03 22:10:57 +02:00
return nil , ErrReloadNeeded
2024-11-06 13:14:12 +01:00
}
2024-11-06 16:48:42 +01:00
log . Trace ( ) . Msg ( "Media not found for direct download, requesting and waiting" )
2024-11-06 13:14:12 +01:00
err = waClient . requestAndWaitDirectMedia ( ctx , msg . ID , keys )
if err != nil {
2024-11-06 16:48:42 +01:00
log . Trace ( ) . Err ( err ) . Msg ( "Failed to wait for media for direct download" )
2025-12-03 22:10:57 +02:00
return nil , err
2024-11-06 13:14:12 +01:00
}
2024-11-06 16:48:42 +01:00
log . Trace ( ) . Msg ( "Retrying download after successful retry" )
2025-05-14 14:40:03 +03:00
err = waClient . Client . DownloadToFile ( ctx , keys , f )
2024-11-06 13:14:12 +01:00
}
if errors . Is ( err , whatsmeow . ErrFileLengthMismatch ) || errors . Is ( err , whatsmeow . ErrInvalidMediaSHA256 ) {
zerolog . Ctx ( ctx ) . Warn ( ) . Err ( err ) . Msg ( "Mismatching media checksums in message. Ignoring because WhatsApp seems to ignore them too" )
} else if err != nil {
2025-12-03 22:10:57 +02:00
return nil , err
2024-11-06 13:14:12 +01:00
}
2025-05-26 23:51:46 +08:00
2025-12-03 22:10:57 +02:00
mime := keys . MimeType
if mime == "application/was" {
2025-05-26 23:51:46 +08:00
if _ , err := f . Seek ( 0 , io . SeekStart ) ; err != nil {
2025-12-03 22:10:57 +02:00
return nil , fmt . Errorf ( "failed to seek to start of sticker zip: %w" , err )
2025-05-26 23:51:46 +08:00
} else if zipData , err := io . ReadAll ( f ) ; err != nil {
2025-12-03 22:10:57 +02:00
return nil , fmt . Errorf ( "failed to read sticker zip: %w" , err )
2025-05-26 23:51:46 +08:00
} else if data , err := msgconv . ExtractAnimatedSticker ( zipData ) ; err != nil {
2025-12-03 22:10:57 +02:00
return nil , fmt . Errorf ( "failed to extract animated sticker: %w %x" , err , zipData )
2025-05-26 23:51:46 +08:00
} else if _ , err := f . WriteAt ( data , 0 ) ; err != nil {
2025-12-03 22:10:57 +02:00
return nil , fmt . Errorf ( "failed to write animated sticker to file: %w" , err )
2025-05-26 23:51:46 +08:00
} else if err := f . Truncate ( int64 ( len ( data ) ) ) ; err != nil {
2025-12-03 22:10:57 +02:00
return nil , fmt . Errorf ( "failed to truncate animated sticker file: %w" , err )
2025-05-26 23:51:46 +08:00
}
2025-12-03 22:10:57 +02:00
mime = "video/lottie+json"
2025-05-26 23:51:46 +08:00
}
2025-12-03 22:10:57 +02:00
return & mediaproxy . FileMeta {
ContentType : mime ,
} , nil
2024-11-06 13:14:12 +01:00
} ,
} , nil
}
type directMediaRetry struct {
sync . Mutex
resultURL string
wait * exsync . Event
requested bool
resultType waMmsRetry . MediaRetryNotification_ResultType
}
func ( wa * WhatsAppClient ) getDirectMediaRetryState ( msgID networkid . MessageID , create bool ) * directMediaRetry {
wa . directMediaLock . Lock ( )
defer wa . directMediaLock . Unlock ( )
retry , ok := wa . directMediaRetries [ msgID ]
if ! ok && create {
retry = & directMediaRetry {
wait : exsync . NewEvent ( ) ,
}
wa . directMediaRetries [ msgID ] = retry
}
return retry
}
func ( wa * WhatsAppClient ) requestAndWaitDirectMedia ( ctx context . Context , rawMsgID networkid . MessageID , keys * msgconv . FailedMediaKeys ) error {
2024-11-06 16:48:42 +01:00
state , err := wa . requestDirectMedia ( ctx , rawMsgID , keys . Key )
2024-11-06 13:14:12 +01:00
if err != nil {
return err
}
select {
case <- state . wait . GetChan ( ) :
if state . resultURL != "" {
keys . DirectPath = state . resultURL
return nil
}
switch state . resultType {
case waMmsRetry . MediaRetryNotification_NOT_FOUND :
2026-01-08 16:34:38 +00:00
return mautrix . MNotFound . WithMessage ( "This media was not found on your phone." )
case waMmsRetry . MediaRetryNotification_DECRYPTION_ERROR :
return mautrix . MNotFound . WithMessage ( "Unable to retrieve media: phone reported a decryption error. The original message may have been deleted." )
case waMmsRetry . MediaRetryNotification_GENERAL_ERROR :
2026-01-29 15:25:56 +00:00
return mautrix . MNotFound . WithMessage ( "Unable to retrieve media: phone returned an error. Please ensure your phone is connected to the internet and WhatsApp is running." ) . WithCanRetry ( true )
2024-11-06 13:14:12 +01:00
default :
2026-01-29 15:25:56 +00:00
return mautrix . MNotFound . WithMessage ( fmt . Sprintf ( "Unable to retrieve media: phone returned error code %d" , state . resultType ) ) . WithCanRetry ( true )
2024-11-06 13:14:12 +01:00
}
case <- time . After ( 30 * time . Second ) :
2026-01-29 15:25:56 +00:00
return mautrix . MNotFound . WithMessage ( "Phone did not respond in time. Please ensure your phone is connected to the internet and WhatsApp is open." ) . WithStatus ( http . StatusGatewayTimeout ) . WithCanRetry ( true )
2024-11-06 13:14:12 +01:00
case <- ctx . Done ( ) :
return ctx . Err ( )
}
}
2024-11-06 16:48:42 +01:00
func ( wa * WhatsAppClient ) requestDirectMedia ( ctx context . Context , rawMsgID networkid . MessageID , key [ ] byte ) ( * directMediaRetry , error ) {
2024-11-06 13:14:12 +01:00
state := wa . getDirectMediaRetryState ( rawMsgID , true )
state . Lock ( )
defer state . Unlock ( )
if ! state . requested {
2024-11-06 16:48:42 +01:00
zerolog . Ctx ( ctx ) . Debug ( ) . Msg ( "Sending request for missing media in direct download" )
2025-10-27 16:20:47 +02:00
err := wa . sendMediaRequestDirect ( ctx , rawMsgID , key )
2024-11-06 13:14:12 +01:00
if err != nil {
return nil , fmt . Errorf ( "failed to send media retry request: %w" , err )
}
state . requested = true
2024-11-06 16:48:42 +01:00
} else {
zerolog . Ctx ( ctx ) . Debug ( ) . Msg ( "Media retry request already sent previously, just waiting for response" )
2024-11-06 13:14:12 +01:00
}
return state , nil
}
func ( wa * WhatsAppClient ) receiveDirectMediaRetry ( ctx context . Context , msg * database . Message , retry * events . MediaRetry ) {
state := wa . getDirectMediaRetryState ( msg . ID , false )
if state != nil {
state . Lock ( )
defer func ( ) {
state . wait . Set ( )
state . Unlock ( )
} ( )
}
log := zerolog . Ctx ( ctx )
var keys msgconv . FailedMediaKeys
err := json . Unmarshal ( msg . Metadata . ( * waid . MessageMetadata ) . DirectMediaMeta , & keys )
if err != nil {
log . Err ( err ) . Msg ( "Failed to parse direct media metadata for media retry" )
return
}
retryData , err := whatsmeow . DecryptMediaRetryNotification ( retry , keys . Key )
if err != nil {
log . Warn ( ) . Err ( err ) . Msg ( "Failed to decrypt media retry notification" )
return
}
2025-07-02 21:01:23 +03:00
if state != nil {
state . resultType = retryData . GetResult ( )
}
2024-11-06 13:14:12 +01:00
if retryData . GetResult ( ) != waMmsRetry . MediaRetryNotification_SUCCESS {
errorName := waMmsRetry . MediaRetryNotification_ResultType_name [ int32 ( retryData . GetResult ( ) ) ]
if retryData . GetDirectPath ( ) == "" {
log . Warn ( ) . Str ( "error_name" , errorName ) . Msg ( "Got error response in media retry notification" )
log . Debug ( ) . Any ( "error_content" , retryData ) . Msg ( "Full error response content" )
return
}
log . Debug ( ) . Msg ( "Got error response in media retry notification, but response also contains a new download URL" )
}
keys . DirectPath = retryData . GetDirectPath ( )
msg . Metadata . ( * waid . MessageMetadata ) . DirectMediaMeta , err = json . Marshal ( keys )
if err != nil {
log . Err ( err ) . Msg ( "Failed to marshal updated direct media metadata" )
} else if err = wa . Main . Bridge . DB . Message . Update ( ctx , msg ) ; err != nil {
log . Err ( err ) . Msg ( "Failed to update message with new direct media metadata" )
}
if state != nil {
state . resultURL = retryData . GetDirectPath ( )
}
}