@ -11,7 +11,6 @@ import (
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
"github.com/aldinokemal/go-whatsapp-web-multidevice/internal/websocket"
"github.com/aldinokemal/go-whatsapp-web-multidevice/internal/websocket"
pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow"
@ -25,12 +24,7 @@ import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/proto"
)
)
var (
cli * whatsmeow . Client
log waLog . Logger
historySyncID int32
startupTime = time . Now ( ) . Unix ( )
)
// Type definitions
type ExtractedMedia struct {
type ExtractedMedia struct {
MediaPath string ` json:"media_path" `
MediaPath string ` json:"media_path" `
@ -50,29 +44,26 @@ type evtMessage struct {
QuotedMessage string ` json:"quoted_message,omitempty" `
QuotedMessage string ` json:"quoted_message,omitempty" `
}
}
func InitWaDB ( db_uri string ) * sqlstore . Container {
// Running Whatsapp
log = waLog . Stdout ( "Main" , config . WhatsappLogLevel , true )
dbLog := waLog . Stdout ( "Database" , config . WhatsappLogLevel , true )
// Global variables
var (
cli * whatsmeow . Client
log waLog . Logger
historySyncID int32
startupTime = time . Now ( ) . Unix ( )
)
var storeContainer * sqlstore . Container
var err error
if strings . HasPrefix ( db_uri , "file:" ) {
storeContainer , err = sqlstore . New ( "sqlite3" , db_uri , dbLog )
} else if strings . HasPrefix ( db_uri , "postgres:" ) {
storeContainer , err = sqlstore . New ( "postgres" , db_uri , dbLog )
} else {
log . Errorf ( "Unknown database type: %s" , db_uri )
panic ( pkgError . InternalServerError ( fmt . Sprintf ( "Unknown database type: %s. Currently only sqlite3(file:) and postgres are supported" , db_uri ) ) )
// InitDatabase creates and returns a database store container based on the configured URI
func InitDatabase ( dbUri string , dbLog waLog . Logger ) ( * sqlstore . Container , error ) {
if strings . HasPrefix ( dbUri , "file:" ) {
return sqlstore . New ( "sqlite3" , dbUri , dbLog )
} else if strings . HasPrefix ( dbUri , "postgres:" ) {
return sqlstore . New ( "postgres" , dbUri , dbLog )
}
}
if err != nil {
log . Errorf ( "Failed to connect to database: %v" , err )
panic ( pkgError . InternalServerError ( fmt . Sprintf ( "Failed to connect to database: %v" , err ) ) )
}
return storeContainer
return nil , fmt . Errorf ( "unknown database type: %s. Currently only sqlite3(file:) and postgres are supported" , dbUri )
}
}
// InitWaCLI initializes the WhatsApp client
func InitWaCLI ( storeContainer , keysStoreContainer * sqlstore . Container ) * whatsmeow . Client {
func InitWaCLI ( storeContainer , keysStoreContainer * sqlstore . Container ) * whatsmeow . Client {
device , err := storeContainer . GetFirstDevice ( )
device , err := storeContainer . GetFirstDevice ( )
if err != nil {
if err != nil {
@ -85,10 +76,12 @@ func InitWaCLI(storeContainer, keysStoreContainer *sqlstore.Container) *whatsmeo
panic ( "No device found" )
panic ( "No device found" )
}
}
// Configure device properties
osName := fmt . Sprintf ( "%s %s" , config . AppOs , config . AppVersion )
osName := fmt . Sprintf ( "%s %s" , config . AppOs , config . AppVersion )
store . DeviceProps . PlatformType = & config . AppPlatform
store . DeviceProps . PlatformType = & config . AppPlatform
store . DeviceProps . Os = & osName
store . DeviceProps . Os = & osName
// Configure a separated database for accelerating encryption caching
if keysStoreContainer != nil && device . ID != nil {
if keysStoreContainer != nil && device . ID != nil {
innerStore := sqlstore . NewSQLStore ( keysStoreContainer , * device . ID )
innerStore := sqlstore . NewSQLStore ( keysStoreContainer , * device . ID )
device . Identities = innerStore
device . Identities = innerStore
@ -99,6 +92,7 @@ func InitWaCLI(storeContainer, keysStoreContainer *sqlstore.Container) *whatsmeo
device . PrivacyTokens = innerStore
device . PrivacyTokens = innerStore
}
}
// Create and configure the client
cli = whatsmeow . NewClient ( device , waLog . Stdout ( "Client" , config . WhatsappLogLevel , true ) )
cli = whatsmeow . NewClient ( device , waLog . Stdout ( "Client" , config . WhatsappLogLevel , true ) )
cli . EnableAutoReconnect = true
cli . EnableAutoReconnect = true
cli . AutoTrustIdentity = true
cli . AutoTrustIdentity = true
@ -107,120 +101,205 @@ func InitWaCLI(storeContainer, keysStoreContainer *sqlstore.Container) *whatsmeo
return cli
return cli
}
}
// handler is the main event handler for WhatsApp events
func handler ( rawEvt interface { } ) {
func handler ( rawEvt interface { } ) {
switch evt := rawEvt . ( type ) {
switch evt := rawEvt . ( type ) {
case * events . DeleteForMe :
case * events . DeleteForMe :
log . Infof ( "Deleted message %s for %s" , evt . MessageID , evt . SenderJID . String ( ) )
handleDeleteForMe ( evt )
case * events . AppStateSyncComplete :
case * events . AppStateSyncComplete :
if len ( cli . Store . PushName ) > 0 && evt . Name == appstate . WAPatchCriticalBlock {
err := cli . SendPresence ( types . PresenceAvailable )
if err != nil {
log . Warnf ( "Failed to send available presence: %v" , err )
} else {
log . Infof ( "Marked self as available" )
}
}
handleAppStateSyncComplete ( evt )
case * events . PairSuccess :
case * events . PairSuccess :
websocket . Broadcast <- websocket . BroadcastMessage {
Code : "LOGIN_SUCCESS" ,
Message : fmt . Sprintf ( "Successfully pair with %s" , evt . ID . String ( ) ) ,
}
handlePairSuccess ( evt )
case * events . LoggedOut :
case * events . LoggedOut :
websocket . Broadcast <- websocket . BroadcastMessage {
Code : "LIST_DEVICES" ,
Result : nil ,
}
handleLoggedOut ( )
case * events . Connected , * events . PushNameSetting :
case * events . Connected , * events . PushNameSetting :
if len ( cli . Store . PushName ) == 0 {
return
}
handleConnectionEvents ( )
case * events . StreamReplaced :
handleStreamReplaced ( )
case * events . Message :
handleMessage ( evt )
case * events . Receipt :
handleReceipt ( evt )
case * events . Presence :
handlePresence ( evt )
case * events . HistorySync :
handleHistorySync ( evt )
case * events . AppState :
handleAppState ( evt )
}
}
// Event handler functions
func handleDeleteForMe ( evt * events . DeleteForMe ) {
log . Infof ( "Deleted message %s for %s" , evt . MessageID , evt . SenderJID . String ( ) )
}
// Send presence available when connecting and when the pushname is changed.
// This makes sure that outgoing messages always have the right pushname.
err := cli . SendPresence ( types . PresenceAvailable )
if err != nil {
func handleAppStateSyncComplete ( evt * events . AppStateSyncComplete ) {
if len ( cli . Store . PushName ) > 0 && evt . Name == appstate . WAPatchCriticalBlock {
if err := cli . SendPresence ( types . PresenceAvailable ) ; err != nil {
log . Warnf ( "Failed to send available presence: %v" , err )
log . Warnf ( "Failed to send available presence: %v" , err )
} else {
} else {
log . Infof ( "Marked self as available" )
log . Infof ( "Marked self as available" )
}
}
case * events . StreamReplaced :
os . Exit ( 0 )
case * events . Message :
metaParts := [ ] string {
fmt . Sprintf ( "pushname: %s" , evt . Info . PushName ) ,
fmt . Sprintf ( "timestamp: %s" , evt . Info . Timestamp ) ,
}
if evt . Info . Type != "" {
metaParts = append ( metaParts , fmt . Sprintf ( "type: %s" , evt . Info . Type ) )
}
if evt . Info . Category != "" {
metaParts = append ( metaParts , fmt . Sprintf ( "category: %s" , evt . Info . Category ) )
}
if evt . IsViewOnce {
metaParts = append ( metaParts , "view once" )
}
}
}
log . Infof ( "Received message %s from %s (%s): %+v" , evt . Info . ID , evt . Info . SourceString ( ) , strings . Join ( metaParts , ", " ) , evt . Message )
message := ExtractMessageText ( evt )
utils . RecordMessage ( evt . Info . ID , evt . Info . Sender . String ( ) , message )
func handlePairSuccess ( evt * events . PairSuccess ) {
websocket . Broadcast <- websocket . BroadcastMessage {
Code : "LOGIN_SUCCESS" ,
Message : fmt . Sprintf ( "Successfully pair with %s" , evt . ID . String ( ) ) ,
}
}
if img := evt . Message . GetImageMessage ( ) ; img != nil {
if path , err := ExtractMedia ( config . PathStorages , img ) ; err != nil {
log . Errorf ( "Failed to download image: %v" , err )
} else {
log . Infof ( "Image downloaded to %s" , path )
}
}
func handleLoggedOut ( ) {
websocket . Broadcast <- websocket . BroadcastMessage {
Code : "LIST_DEVICES" ,
Result : nil ,
}
}
if config . WhatsappAutoReplyMessage != "" &&
! isGroupJid ( evt . Info . Chat . String ( ) ) &&
! strings . Contains ( evt . Info . SourceString ( ) , "broadcast" ) {
_ , _ = cli . SendMessage ( context . Background ( ) , evt . Info . Sender , & waE2E . Message { Conversation : proto . String ( config . WhatsappAutoReplyMessage ) } )
}
func handleConnectionEvents ( ) {
if len ( cli . Store . PushName ) == 0 {
return
}
if len ( config . WhatsappWebhook ) > 0 &&
! strings . Contains ( evt . Info . SourceString ( ) , "broadcast" ) &&
! isFromMySelf ( evt . Info . SourceString ( ) ) {
go func ( evt * events . Message ) {
if err := forwardToWebhook ( evt ) ; err != nil {
logrus . Error ( "Failed forward to webhook: " , err )
}
} ( evt )
}
case * events . Receipt :
if evt . Type == types . ReceiptTypeRead || evt . Type == types . ReceiptTypeReadSelf {
log . Infof ( "%v was read by %s at %s" , evt . MessageIDs , evt . SourceString ( ) , evt . Timestamp )
} else if evt . Type == types . ReceiptTypeDelivered {
log . Infof ( "%s was delivered to %s at %s" , evt . MessageIDs [ 0 ] , evt . SourceString ( ) , evt . Timestamp )
// Send presence available when connecting and when the pushname is changed.
// This makes sure that outgoing messages always have the right pushname.
if err := cli . SendPresence ( types . PresenceAvailable ) ; err != nil {
log . Warnf ( "Failed to send available presence: %v" , err )
} else {
log . Infof ( "Marked self as available" )
}
}
func handleStreamReplaced ( ) {
os . Exit ( 0 )
}
func handleMessage ( evt * events . Message ) {
// Log message metadata
metaParts := buildMessageMetaParts ( evt )
log . Infof ( "Received message %s from %s (%s): %+v" ,
evt . Info . ID ,
evt . Info . SourceString ( ) ,
strings . Join ( metaParts , ", " ) ,
evt . Message ,
)
// Record the message
message := ExtractMessageText ( evt )
utils . RecordMessage ( evt . Info . ID , evt . Info . Sender . String ( ) , message )
// Handle image message if present
handleImageMessage ( evt )
// Handle auto-reply if configured
handleAutoReply ( evt )
// Forward to webhook if configured
handleWebhookForward ( evt )
}
func buildMessageMetaParts ( evt * events . Message ) [ ] string {
metaParts := [ ] string {
fmt . Sprintf ( "pushname: %s" , evt . Info . PushName ) ,
fmt . Sprintf ( "timestamp: %s" , evt . Info . Timestamp ) ,
}
if evt . Info . Type != "" {
metaParts = append ( metaParts , fmt . Sprintf ( "type: %s" , evt . Info . Type ) )
}
if evt . Info . Category != "" {
metaParts = append ( metaParts , fmt . Sprintf ( "category: %s" , evt . Info . Category ) )
}
if evt . IsViewOnce {
metaParts = append ( metaParts , "view once" )
}
return metaParts
}
func handleImageMessage ( evt * events . Message ) {
if img := evt . Message . GetImageMessage ( ) ; img != nil {
if path , err := ExtractMedia ( config . PathStorages , img ) ; err != nil {
log . Errorf ( "Failed to download image: %v" , err )
} else {
log . Infof ( "Image downloaded to %s" , path )
}
}
case * events . Presence :
if evt . Unavailable {
if evt . LastSeen . IsZero ( ) {
log . Infof ( "%s is now offline" , evt . From )
} else {
log . Infof ( "%s is now offline (last seen: %s)" , evt . From , evt . LastSeen )
}
}
func handleAutoReply ( evt * events . Message ) {
if config . WhatsappAutoReplyMessage != "" &&
! isGroupJid ( evt . Info . Chat . String ( ) ) &&
! evt . Info . IsIncomingBroadcast ( ) &&
evt . Message . GetExtendedTextMessage ( ) . GetText ( ) != "" {
_ , _ = cli . SendMessage (
context . Background ( ) ,
FormatJID ( evt . Info . Sender . String ( ) ) ,
& waE2E . Message { Conversation : proto . String ( config . WhatsappAutoReplyMessage ) } ,
)
}
}
func handleWebhookForward ( evt * events . Message ) {
if len ( config . WhatsappWebhook ) > 0 &&
! strings . Contains ( evt . Info . SourceString ( ) , "broadcast" ) &&
! isFromMySelf ( evt . Info . SourceString ( ) ) {
go func ( evt * events . Message ) {
if err := forwardToWebhook ( evt ) ; err != nil {
logrus . Error ( "Failed forward to webhook: " , err )
}
}
} ( evt )
}
}
func handleReceipt ( evt * events . Receipt ) {
if evt . Type == types . ReceiptTypeRead || evt . Type == types . ReceiptTypeReadSelf {
log . Infof ( "%v was read by %s at %s" , evt . MessageIDs , evt . SourceString ( ) , evt . Timestamp )
} else if evt . Type == types . ReceiptTypeDelivered {
log . Infof ( "%s was delivered to %s at %s" , evt . MessageIDs [ 0 ] , evt . SourceString ( ) , evt . Timestamp )
}
}
func handlePresence ( evt * events . Presence ) {
if evt . Unavailable {
if evt . LastSeen . IsZero ( ) {
log . Infof ( "%s is now offline" , evt . From )
} else {
} else {
log . Infof ( "%s is now online" , evt . From )
}
case * events . HistorySync :
id := atomic . AddInt32 ( & historySyncID , 1 )
fileName := fmt . Sprintf ( "%s/history-%d-%s-%d-%s.json" , config . PathStorages , startupTime , cli . Store . ID . String ( ) , id , evt . Data . SyncType . String ( ) )
file , err := os . OpenFile ( fileName , os . O_WRONLY | os . O_CREATE , 0600 )
if err != nil {
log . Errorf ( "Failed to open file to write history sync: %v" , err )
return
}
enc := json . NewEncoder ( file )
enc . SetIndent ( "" , " " )
err = enc . Encode ( evt . Data )
if err != nil {
log . Errorf ( "Failed to write history sync: %v" , err )
return
log . Infof ( "%s is now offline (last seen: %s)" , evt . From , evt . LastSeen )
}
}
log . Infof ( "Wrote history sync to %s" , fileName )
_ = file . Close ( )
case * events . AppState :
log . Debugf ( "App state event: %+v / %+v" , evt . Index , evt . SyncActionValue )
} else {
log . Infof ( "%s is now online" , evt . From )
}
}
func handleHistorySync ( evt * events . HistorySync ) {
id := atomic . AddInt32 ( & historySyncID , 1 )
fileName := fmt . Sprintf ( "%s/history-%d-%s-%d-%s.json" ,
config . PathStorages ,
startupTime ,
cli . Store . ID . String ( ) ,
id ,
evt . Data . SyncType . String ( ) ,
)
file , err := os . OpenFile ( fileName , os . O_WRONLY | os . O_CREATE , 0600 )
if err != nil {
log . Errorf ( "Failed to open file to write history sync: %v" , err )
return
}
defer file . Close ( )
enc := json . NewEncoder ( file )
enc . SetIndent ( "" , " " )
if err = enc . Encode ( evt . Data ) ; err != nil {
log . Errorf ( "Failed to write history sync: %v" , err )
return
}
}
log . Infof ( "Wrote history sync to %s" , fileName )
}
func handleAppState ( evt * events . AppState ) {
log . Debugf ( "App state event: %+v / %+v" , evt . Index , evt . SyncActionValue )
}
}