From c2c94d560ee4c7444042925310d74771eb7b769d Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Thu, 19 Dec 2024 22:04:06 +0700 Subject: [PATCH] fix: null webhook message when edit (#221) * fix: update deps & bug fix fix(go.mod): update module dependencies to latest versions to ensure compatibility and security fix(whatsapp.go): enhance event message handling by adding quoted message field and streamline message building and media extraction functions * fix: make webhook call async and add retry mechanism fix(init.go): wrap forwardToWebhook call in a goroutine to make it async feat(webhook.go): add timestamp field to the webhook payload fix(webhook.go): implement retry logic for submitWebhook with exponential backoff * feat: update version * feat: add max download size validation for media files - settings.go: add WhatsappSettingMaxDownloadSize for file size validation (500MB) - utils.go: check file size before writing media to disk, returning an error if it exceeds the max limit * fix: improve error handling and logging fix(utils.go): replace panic with logrus error logging and return false fix(webhook.go): add logrus error logging when media download fails fix(webhook.go): add logrus info and warning logging for webhook submission attempts * fix: add omitempty tags to JSON fields to avoid empty values refactor(webhook.go): restructure createPayload to check and add fields conditionally, improving clarity and preventing unnecessary fields refactor(webhook.go): handle media extraction only when media is present, improving readability and maintainability * fix: add nil check for device in whatsapp init fix(init.go): add check for nil device and log error before panic Ensure proper error handling when no device is found --- src/config/settings.go | 21 +- src/go.mod | 20 +- src/go.sum | 20 ++ src/pkg/whatsapp/init.go | 202 ++++++++++++++ src/pkg/whatsapp/utils.go | 252 ++++++++++++++++++ src/pkg/whatsapp/webhook.go | 166 ++++++++++++ src/pkg/whatsapp/whatsapp.go | 498 ----------------------------------- 7 files changed, 661 insertions(+), 518 deletions(-) create mode 100644 src/pkg/whatsapp/init.go create mode 100644 src/pkg/whatsapp/utils.go create mode 100644 src/pkg/whatsapp/webhook.go delete mode 100644 src/pkg/whatsapp/whatsapp.go diff --git a/src/config/settings.go b/src/config/settings.go index 06773bf..6941493 100644 --- a/src/config/settings.go +++ b/src/config/settings.go @@ -5,7 +5,7 @@ import ( ) var ( - AppVersion = "v4.21.1" + AppVersion = "v4.22.0" AppPort = "3000" AppDebug = false AppOs = "AldinoKemal" @@ -19,13 +19,14 @@ var ( DBName = "whatsapp.db" - WhatsappAutoReplyMessage string - WhatsappWebhook string - WhatsappWebhookSecret = "secret" - WhatsappLogLevel = "ERROR" - WhatsappSettingMaxFileSize int64 = 50000000 // 50MB - WhatsappSettingMaxVideoSize int64 = 100000000 // 100MB - WhatsappTypeUser = "@s.whatsapp.net" - WhatsappTypeGroup = "@g.us" - WhatsappAccountValidation = true + WhatsappAutoReplyMessage string + WhatsappWebhook string + WhatsappWebhookSecret = "secret" + WhatsappLogLevel = "ERROR" + WhatsappSettingMaxFileSize int64 = 50000000 // 50MB + WhatsappSettingMaxVideoSize int64 = 100000000 // 100MB + WhatsappSettingMaxDownloadSize int64 = 500000000 // 500MB + WhatsappTypeUser = "@s.whatsapp.net" + WhatsappTypeGroup = "@g.us" + WhatsappAccountValidation = true ) diff --git a/src/go.mod b/src/go.mod index eabb4c0..0f1c7b5 100644 --- a/src/go.mod +++ b/src/go.mod @@ -17,11 +17,11 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.8.1 - github.com/stretchr/testify v1.9.0 - github.com/valyala/fasthttp v1.57.0 + github.com/stretchr/testify v1.10.0 + github.com/valyala/fasthttp v1.58.0 go.mau.fi/libsignal v0.1.1 - go.mau.fi/whatsmeow v0.0.0-20241114122515-3c0f25d54c22 - google.golang.org/protobuf v1.35.2 + go.mau.fi/whatsmeow v0.0.0-20241215104421-68b0856cce22 + google.golang.org/protobuf v1.36.0 ) require ( @@ -30,7 +30,7 @@ require ( github.com/andybalholm/cascadia v1.3.2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fasthttp/websocket v1.5.10 // indirect + github.com/fasthttp/websocket v1.5.11 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/utils v1.1.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -47,11 +47,11 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - go.mau.fi/util v0.8.2 // indirect - golang.org/x/crypto v0.29.0 // indirect - golang.org/x/image v0.22.0 // indirect - golang.org/x/net v0.31.0 // indirect - golang.org/x/sys v0.27.0 // indirect + go.mau.fi/util v0.8.3 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/image v0.23.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/src/go.sum b/src/go.sum index 467c4b9..aedb576 100644 --- a/src/go.sum +++ b/src/go.sum @@ -24,6 +24,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fasthttp/websocket v1.5.10 h1:bc7NIGyrg1L6sd5pRzCIbXpro54SZLEluZCu0rOpcN4= github.com/fasthttp/websocket v1.5.10/go.mod h1:BwHeuXGWzCW1/BIKUKD3+qfCl+cTdsHu/f243NcAI/Q= +github.com/fasthttp/websocket v1.5.11 h1:TCO3H2VSxeTJQ+Ij+w8q7UBvdVedMOy/G7aZ0a6V19s= +github.com/fasthttp/websocket v1.5.11/go.mod h1:QWILjDXurHFN5519nH2Pe9rtRuKZ/OIx/rlBF9coYds= github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -95,6 +97,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= @@ -103,6 +107,8 @@ github.com/valyala/fasthttp v1.56.0 h1:bEZdJev/6LCBlpdORfrLu/WOZXXxvrUQSiyniuaoW github.com/valyala/fasthttp v1.56.0/go.mod h1:sReBt3XZVnudxuLOx4J/fMrJVorWRiWY2koQKgABiVI= github.com/valyala/fasthttp v1.57.0 h1:Xw8SjWGEP/+wAAgyy5XTvgrWlOD1+TxbbvNADYCm1Tg= github.com/valyala/fasthttp v1.57.0/go.mod h1:h6ZBaPRlzpZ6O3H5t2gEk1Qi33+TmLvfwgLLp0t9CpE= +github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE= +github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= @@ -117,6 +123,8 @@ go.mau.fi/util v0.8.1 h1:Ga43cz6esQBYqcjZ/onRoVnYWoUwjWbsxVeJg2jOTSo= go.mau.fi/util v0.8.1/go.mod h1:T1u/rD2rzidVrBLyaUdPpZiJdP/rsyi+aTzn0D+Q6wc= go.mau.fi/util v0.8.2 h1:zWbVHwdRKwI6U9AusmZ8bwgcLosikwbb4GGqLrNr1YE= go.mau.fi/util v0.8.2/go.mod h1:BHHC9R2WLMJd1bwTZfTcFxUgRFmUgUmiWcT4RbzUgiA= +go.mau.fi/util v0.8.3 h1:sulhXtfquMrQjsOP67x9CzWVBYUwhYeoo8hNQIpCWZ4= +go.mau.fi/util v0.8.3/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY= go.mau.fi/whatsmeow v0.0.0-20240821142752-3d63c6fcc1a7 h1:Aa4uov0rM0SQQ7Fc/TZZpmQEGksie2SVTv/UuCJwViI= go.mau.fi/whatsmeow v0.0.0-20240821142752-3d63c6fcc1a7/go.mod h1:BhHKalSq0qNtSCuGIUIvoJyU5KbT4a7k8DQ5yw1Ssk4= go.mau.fi/whatsmeow v0.0.0-20240911102933-bb3364aa3986 h1:7X+3826qoRBHPCtxY89tqMcYEsi9+OuWE6hHZfRc0qI= @@ -129,6 +137,8 @@ go.mau.fi/whatsmeow v0.0.0-20241027175758-cd900353e4a7 h1:0qujZcpt0G1QFCrTgrXvnV go.mau.fi/whatsmeow v0.0.0-20241027175758-cd900353e4a7/go.mod h1:UvaXcdb8y5Mryj2LSXAMw7u4/exnWJIXn8Gvpmf6ndI= go.mau.fi/whatsmeow v0.0.0-20241114122515-3c0f25d54c22 h1:MqyzS+gnDLTGfbmQvmzbe7ZcsyEgkw2WDTWaC3AVnOo= go.mau.fi/whatsmeow v0.0.0-20241114122515-3c0f25d54c22/go.mod h1:TsBdbxqTsOTiKXj2M/ipWWFGKebU+WcipK4LZnHfEJ4= +go.mau.fi/whatsmeow v0.0.0-20241215104421-68b0856cce22 h1:0jqmWhJeVvCnIFcMqETqqHwmlgPSDZKYbeLPhCqeHE0= +go.mau.fi/whatsmeow v0.0.0-20241215104421-68b0856cce22/go.mod h1:56ExppHW+lqAZA4AGmCg0+qHrP5TZuIN8oiubTULSVA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= @@ -139,6 +149,8 @@ golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys= @@ -148,6 +160,8 @@ golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -163,6 +177,8 @@ golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -185,6 +201,8 @@ golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -207,6 +225,8 @@ google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFyt google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= +google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/src/pkg/whatsapp/init.go b/src/pkg/whatsapp/init.go new file mode 100644 index 0000000..5a153d8 --- /dev/null +++ b/src/pkg/whatsapp/init.go @@ -0,0 +1,202 @@ +package whatsapp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "sync/atomic" + "time" + + "github.com/aldinokemal/go-whatsapp-web-multidevice/config" + "github.com/aldinokemal/go-whatsapp-web-multidevice/internal/websocket" + pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" + "github.com/sirupsen/logrus" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/appstate" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/store" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + waLog "go.mau.fi/whatsmeow/util/log" + "google.golang.org/protobuf/proto" +) + +var ( + cli *whatsmeow.Client + log waLog.Logger + historySyncID int32 + startupTime = time.Now().Unix() +) + +type ExtractedMedia struct { + MediaPath string `json:"media_path"` + MimeType string `json:"mime_type"` + Caption string `json:"caption"` +} + +type evtReaction struct { + ID string `json:"id,omitempty"` + Message string `json:"message,omitempty"` +} + +type evtMessage struct { + ID string `json:"id,omitempty"` + Text string `json:"text,omitempty"` + RepliedId string `json:"replied_id,omitempty"` + QuotedMessage string `json:"quoted_message,omitempty"` +} + +func InitWaDB() *sqlstore.Container { + // Running Whatsapp + log = waLog.Stdout("Main", config.WhatsappLogLevel, true) + dbLog := waLog.Stdout("Database", config.WhatsappLogLevel, true) + storeContainer, err := sqlstore.New("sqlite3", fmt.Sprintf("file:%s/%s?_foreign_keys=off", config.PathStorages, config.DBName), 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 +} + +func InitWaCLI(storeContainer *sqlstore.Container) *whatsmeow.Client { + device, err := storeContainer.GetFirstDevice() + if err != nil { + log.Errorf("Failed to get device: %v", err) + panic(err) + } + + if device == nil { + log.Errorf("No device found") + panic("No device found") + } + + osName := fmt.Sprintf("%s %s", config.AppOs, config.AppVersion) + store.DeviceProps.PlatformType = &config.AppPlatform + store.DeviceProps.Os = &osName + cli = whatsmeow.NewClient(device, waLog.Stdout("Client", config.WhatsappLogLevel, true)) + cli.EnableAutoReconnect = true + cli.AutoTrustIdentity = true + cli.AddEventHandler(handler) + + return cli +} + +func handler(rawEvt interface{}) { + switch evt := rawEvt.(type) { + case *events.DeleteForMe: + log.Infof("Deleted message %s for %s", evt.MessageID, evt.SenderJID.String()) + 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") + } + } + case *events.PairSuccess: + websocket.Broadcast <- websocket.BroadcastMessage{ + Code: "LOGIN_SUCCESS", + Message: fmt.Sprintf("Successfully pair with %s", evt.ID.String()), + } + case *events.LoggedOut: + websocket.Broadcast <- websocket.BroadcastMessage{ + Code: "LIST_DEVICES", + Result: nil, + } + case *events.Connected, *events.PushNameSetting: + if len(cli.Store.PushName) == 0 { + return + } + + // 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 { + log.Warnf("Failed to send available presence: %v", err) + } else { + 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) + + 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) + } + } + + 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)}) + } + + if config.WhatsappWebhook != "" && + !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) + } + 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) + } + } 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("Wrote history sync to %s", fileName) + _ = file.Close() + case *events.AppState: + log.Debugf("App state event: %+v / %+v", evt.Index, evt.SyncActionValue) + } +} diff --git a/src/pkg/whatsapp/utils.go b/src/pkg/whatsapp/utils.go new file mode 100644 index 0000000..f737009 --- /dev/null +++ b/src/pkg/whatsapp/utils.go @@ -0,0 +1,252 @@ +package whatsapp + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "mime" + "os" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "go.mau.fi/whatsmeow/proto/waE2E" + + "github.com/aldinokemal/go-whatsapp-web-multidevice/config" + pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +// ExtractMedia is a helper function to extract media from whatsapp +func ExtractMedia(storageLocation string, mediaFile whatsmeow.DownloadableMessage) (extractedMedia ExtractedMedia, err error) { + if mediaFile == nil { + logrus.Info("Skip download because data is nil") + return extractedMedia, nil + } + + data, err := cli.Download(mediaFile) + if err != nil { + return extractedMedia, err + } + + // Validate file size before writing to disk + maxFileSize := config.WhatsappSettingMaxDownloadSize + if int64(len(data)) > maxFileSize { + return extractedMedia, fmt.Errorf("file size exceeds the maximum limit of %d bytes", maxFileSize) + } + + switch media := mediaFile.(type) { + case *waE2E.ImageMessage: + extractedMedia.MimeType = media.GetMimetype() + extractedMedia.Caption = media.GetCaption() + case *waE2E.AudioMessage: + extractedMedia.MimeType = media.GetMimetype() + case *waE2E.VideoMessage: + extractedMedia.MimeType = media.GetMimetype() + extractedMedia.Caption = media.GetCaption() + case *waE2E.StickerMessage: + extractedMedia.MimeType = media.GetMimetype() + case *waE2E.DocumentMessage: + extractedMedia.MimeType = media.GetMimetype() + extractedMedia.Caption = media.GetCaption() + } + + var extension string + if ext, err := mime.ExtensionsByType(extractedMedia.MimeType); err == nil && len(ext) > 0 { + extension = ext[0] + } else if parts := strings.Split(extractedMedia.MimeType, "/"); len(parts) > 1 { + extension = "." + parts[len(parts)-1] + } + + extractedMedia.MediaPath = fmt.Sprintf("%s/%d-%s%s", storageLocation, time.Now().Unix(), uuid.NewString(), extension) + err = os.WriteFile(extractedMedia.MediaPath, data, 0600) + if err != nil { + return extractedMedia, err + } + return extractedMedia, nil +} + +func SanitizePhone(phone *string) { + if phone != nil && len(*phone) > 0 && !strings.Contains(*phone, "@") { + if len(*phone) <= 15 { + *phone = fmt.Sprintf("%s%s", *phone, config.WhatsappTypeUser) + } else { + *phone = fmt.Sprintf("%s%s", *phone, config.WhatsappTypeGroup) + } + } +} + +func GetPlatformName(deviceID int) string { + switch deviceID { + case 0: + return "UNKNOWN" + case 1: + return "CHROME" + case 2: + return "FIREFOX" + case 3: + return "IE" + case 4: + return "OPERA" + case 5: + return "SAFARI" + case 6: + return "EDGE" + case 7: + return "DESKTOP" + case 8: + return "IPAD" + case 9: + return "ANDROID_TABLET" + case 10: + return "OHANA" + case 11: + return "ALOHA" + case 12: + return "CATALINA" + case 13: + return "TCL_TV" + default: + return "UNKNOWN" + } +} + +func ParseJID(arg string) (types.JID, error) { + if arg[0] == '+' { + arg = arg[1:] + } + if !strings.ContainsRune(arg, '@') { + return types.NewJID(arg, types.DefaultUserServer), nil + } + + recipient, err := types.ParseJID(arg) + if err != nil { + fmt.Printf("invalid JID %s: %v", arg, err) + return recipient, pkgError.ErrInvalidJID + } + + if recipient.User == "" { + fmt.Printf("invalid JID %v: no server specified", arg) + return recipient, pkgError.ErrInvalidJID + } + return recipient, nil +} + +func IsOnWhatsapp(waCli *whatsmeow.Client, jid string) bool { + // only check if the jid a user with @s.whatsapp.net + if strings.Contains(jid, "@s.whatsapp.net") { + data, err := waCli.IsOnWhatsApp([]string{jid}) + if err != nil { + logrus.Error("Failed to check if user is on whatsapp: ", err) + return false + } + + for _, v := range data { + if !v.IsIn { + return false + } + } + } + + return true +} + +func ValidateJidWithLogin(waCli *whatsmeow.Client, jid string) (types.JID, error) { + MustLogin(waCli) + + if config.WhatsappAccountValidation && !IsOnWhatsapp(waCli, jid) { + return types.JID{}, pkgError.InvalidJID(fmt.Sprintf("Phone %s is not on whatsapp", jid)) + } + + return ParseJID(jid) +} + +func MustLogin(waCli *whatsmeow.Client) { + if waCli == nil { + panic(pkgError.InternalServerError("Whatsapp client is not initialized")) + } + if !waCli.IsConnected() { + panic(pkgError.ErrNotConnected) + } else if !waCli.IsLoggedIn() { + panic(pkgError.ErrNotLoggedIn) + } +} + +// isGroupJid is a helper function to check if the message is from group +func isGroupJid(jid string) bool { + return strings.Contains(jid, "@g.us") +} + +// isFromMySelf is a helper function to check if the message is from my self (logged in account) +func isFromMySelf(jid string) bool { + return extractPhoneNumber(jid) == extractPhoneNumber(cli.Store.ID.String()) +} + +// extractPhoneNumber is a helper function to extract the phone number from a JID +func extractPhoneNumber(jid string) string { + regex := regexp.MustCompile(`\d+`) + // Find all matches of the pattern in the JID + matches := regex.FindAllString(jid, -1) + // The first match should be the phone number + if len(matches) > 0 { + return matches[0] + } + // If no matches are found, return an empty string + return "" +} + +func getMessageDigestOrSignature(msg, key []byte) (string, error) { + mac := hmac.New(sha256.New, key) + _, err := mac.Write(msg) + if err != nil { + return "", err + } + return hex.EncodeToString(mac.Sum(nil)), nil +} + +func buildEventMessage(evt *events.Message) (message evtMessage) { + message.Text = evt.Message.GetConversation() + message.ID = evt.Info.ID + + if extendedMessage := evt.Message.GetExtendedTextMessage(); extendedMessage != nil { + message.Text = extendedMessage.GetText() + message.RepliedId = extendedMessage.ContextInfo.GetStanzaID() + message.QuotedMessage = extendedMessage.ContextInfo.GetQuotedMessage().GetConversation() + } else if protocolMessage := evt.Message.GetProtocolMessage(); protocolMessage != nil { + if editedMessage := protocolMessage.GetEditedMessage(); editedMessage != nil { + if extendedText := editedMessage.GetExtendedTextMessage(); extendedText != nil { + message.Text = extendedText.GetText() + message.RepliedId = extendedText.ContextInfo.GetStanzaID() + message.QuotedMessage = extendedText.ContextInfo.GetQuotedMessage().GetConversation() + } + } + } + + return message +} + +func buildEventReaction(evt *events.Message) (waReaction evtReaction) { + if reactionMessage := evt.Message.GetReactionMessage(); reactionMessage != nil { + waReaction.Message = reactionMessage.GetText() + waReaction.ID = reactionMessage.GetKey().GetID() + } + return waReaction +} + +func buildForwarded(evt *events.Message) bool { + if extendedText := evt.Message.GetExtendedTextMessage(); extendedText != nil { + return extendedText.ContextInfo.GetIsForwarded() + } else if protocolMessage := evt.Message.GetProtocolMessage(); protocolMessage != nil { + if editedMessage := protocolMessage.GetEditedMessage(); editedMessage != nil { + if extendedText := editedMessage.GetExtendedTextMessage(); extendedText != nil { + return extendedText.ContextInfo.GetIsForwarded() + } + } + } + return false +} diff --git a/src/pkg/whatsapp/webhook.go b/src/pkg/whatsapp/webhook.go new file mode 100644 index 0000000..104d177 --- /dev/null +++ b/src/pkg/whatsapp/webhook.go @@ -0,0 +1,166 @@ +package whatsapp + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/aldinokemal/go-whatsapp-web-multidevice/config" + pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" + "github.com/sirupsen/logrus" + "go.mau.fi/whatsmeow/types/events" +) + +// forwardToWebhook is a helper function to forward event to webhook url +func forwardToWebhook(evt *events.Message) error { + logrus.Info("Forwarding event to webhook:", config.WhatsappWebhook) + payload, err := createPayload(evt) + if err != nil { + return err + } + + if err = submitWebhook(payload); err != nil { + return err + } + + logrus.Info("Event forwarded to webhook") + return nil +} + +func createPayload(evt *events.Message) (map[string]interface{}, error) { + message := buildEventMessage(evt) + waReaction := buildEventReaction(evt) + forwarded := buildForwarded(evt) + + body := make(map[string]interface{}) + + if from := evt.Info.SourceString(); from != "" { + body["from"] = from + } + if message.Text != "" { + body["message"] = message + } + if pushname := evt.Info.PushName; pushname != "" { + body["pushname"] = pushname + } + if waReaction.Message != "" { + body["reaction"] = waReaction + } + if evt.IsViewOnce { + body["view_once"] = evt.IsViewOnce + } + if forwarded { + body["forwarded"] = forwarded + } + if timestamp := evt.Info.Timestamp.Format(time.RFC3339); timestamp != "" { + body["timestamp"] = timestamp + } + + if audioMedia := evt.Message.GetAudioMessage(); audioMedia != nil { + path, err := ExtractMedia(config.PathMedia, audioMedia) + if err != nil { + logrus.Errorf("Failed to download audio from %s: %v", evt.Info.SourceString(), err) + return nil, pkgError.WebhookError(fmt.Sprintf("Failed to download audio: %v", err)) + } + body["audio"] = path + } + + if contactMessage := evt.Message.GetContactMessage(); contactMessage != nil { + body["contact"] = contactMessage + } + + if documentMedia := evt.Message.GetDocumentMessage(); documentMedia != nil { + path, err := ExtractMedia(config.PathMedia, documentMedia) + if err != nil { + logrus.Errorf("Failed to download document from %s: %v", evt.Info.SourceString(), err) + return nil, pkgError.WebhookError(fmt.Sprintf("Failed to download document: %v", err)) + } + body["document"] = path + } + + if imageMedia := evt.Message.GetImageMessage(); imageMedia != nil { + path, err := ExtractMedia(config.PathMedia, imageMedia) + if err != nil { + logrus.Errorf("Failed to download image from %s: %v", evt.Info.SourceString(), err) + return nil, pkgError.WebhookError(fmt.Sprintf("Failed to download image: %v", err)) + } + body["image"] = path + } + + if listMessage := evt.Message.GetListMessage(); listMessage != nil { + body["list"] = listMessage + } + + if liveLocationMessage := evt.Message.GetLiveLocationMessage(); liveLocationMessage != nil { + body["live_location"] = liveLocationMessage + } + + if locationMessage := evt.Message.GetLocationMessage(); locationMessage != nil { + body["location"] = locationMessage + } + + if orderMessage := evt.Message.GetOrderMessage(); orderMessage != nil { + body["order"] = orderMessage + } + + if stickerMedia := evt.Message.GetStickerMessage(); stickerMedia != nil { + path, err := ExtractMedia(config.PathMedia, stickerMedia) + if err != nil { + logrus.Errorf("Failed to download sticker from %s: %v", evt.Info.SourceString(), err) + return nil, pkgError.WebhookError(fmt.Sprintf("Failed to download sticker: %v", err)) + } + body["sticker"] = path + } + + if videoMedia := evt.Message.GetVideoMessage(); videoMedia != nil { + path, err := ExtractMedia(config.PathMedia, videoMedia) + if err != nil { + logrus.Errorf("Failed to download video from %s: %v", evt.Info.SourceString(), err) + return nil, pkgError.WebhookError(fmt.Sprintf("Failed to download video: %v", err)) + } + body["video"] = path + } + + return body, nil +} + +func submitWebhook(payload map[string]interface{}) error { + client := &http.Client{Timeout: 10 * time.Second} + + postBody, err := json.Marshal(payload) + if err != nil { + return pkgError.WebhookError(fmt.Sprintf("Failed to marshal body: %v", err)) + } + + req, err := http.NewRequest(http.MethodPost, config.WhatsappWebhook, bytes.NewBuffer(postBody)) + if err != nil { + return pkgError.WebhookError(fmt.Sprintf("error when create http object %v", err)) + } + + secretKey := []byte(config.WhatsappWebhookSecret) + signature, err := getMessageDigestOrSignature(postBody, secretKey) + if err != nil { + return pkgError.WebhookError(fmt.Sprintf("error when create signature %v", err)) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature-256", fmt.Sprintf("sha256=%s", signature)) + + var attempt int + var maxAttempts = 5 + var sleepDuration = 1 * time.Second + + for attempt = 0; attempt < maxAttempts; attempt++ { + if _, err = client.Do(req); err == nil { + logrus.Infof("Successfully submitted webhook on attempt %d", attempt+1) + return nil + } + logrus.Warnf("Attempt %d to submit webhook failed: %v", attempt+1, err) + time.Sleep(sleepDuration) + sleepDuration *= 2 + } + + return pkgError.WebhookError(fmt.Sprintf("error when submit webhook after %d attempts: %v", attempt, err)) +} diff --git a/src/pkg/whatsapp/whatsapp.go b/src/pkg/whatsapp/whatsapp.go deleted file mode 100644 index 77e20f8..0000000 --- a/src/pkg/whatsapp/whatsapp.go +++ /dev/null @@ -1,498 +0,0 @@ -package whatsapp - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "mime" - "net/http" - "os" - "regexp" - "strings" - "sync/atomic" - "time" - - "github.com/aldinokemal/go-whatsapp-web-multidevice/config" - "github.com/aldinokemal/go-whatsapp-web-multidevice/internal/websocket" - pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" - "github.com/google/uuid" - "github.com/sirupsen/logrus" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/appstate" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/store" - "go.mau.fi/whatsmeow/store/sqlstore" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - waLog "go.mau.fi/whatsmeow/util/log" - "google.golang.org/protobuf/proto" -) - -var ( - cli *whatsmeow.Client - log waLog.Logger - historySyncID int32 - startupTime = time.Now().Unix() -) - -type ExtractedMedia struct { - MediaPath string `json:"media_path"` - MimeType string `json:"mime_type"` - Caption string `json:"caption"` -} - -type evtReaction struct { - ID string `json:"id"` - Message string `json:"message"` -} - -type evtMessage struct { - ID string `json:"id"` - Text string `json:"text"` - RepliedId string `json:"replied_id"` -} - -func SanitizePhone(phone *string) { - if phone != nil && len(*phone) > 0 && !strings.Contains(*phone, "@") { - if len(*phone) <= 15 { - *phone = fmt.Sprintf("%s%s", *phone, config.WhatsappTypeUser) - } else { - *phone = fmt.Sprintf("%s%s", *phone, config.WhatsappTypeGroup) - } - } -} - -func GetPlatformName(deviceID int) string { - switch deviceID { - case 0: - return "UNKNOWN" - case 1: - return "CHROME" - case 2: - return "FIREFOX" - case 3: - return "IE" - case 4: - return "OPERA" - case 5: - return "SAFARI" - case 6: - return "EDGE" - case 7: - return "DESKTOP" - case 8: - return "IPAD" - case 9: - return "ANDROID_TABLET" - case 10: - return "OHANA" - case 11: - return "ALOHA" - case 12: - return "CATALINA" - case 13: - return "TCL_TV" - default: - return "UNKNOWN" - } -} - -func ParseJID(arg string) (types.JID, error) { - if arg[0] == '+' { - arg = arg[1:] - } - if !strings.ContainsRune(arg, '@') { - return types.NewJID(arg, types.DefaultUserServer), nil - } - - recipient, err := types.ParseJID(arg) - if err != nil { - fmt.Printf("invalid JID %s: %v", arg, err) - return recipient, pkgError.ErrInvalidJID - } - - if recipient.User == "" { - fmt.Printf("invalid JID %v: no server specified", arg) - return recipient, pkgError.ErrInvalidJID - } - return recipient, nil -} - -func IsOnWhatsapp(waCli *whatsmeow.Client, jid string) bool { - // only check if the jid a user with @s.whatsapp.net - if strings.Contains(jid, "@s.whatsapp.net") { - data, err := waCli.IsOnWhatsApp([]string{jid}) - if err != nil { - panic(pkgError.InvalidJID(err.Error())) - } - - for _, v := range data { - if !v.IsIn { - return false - } - } - } - - return true -} - -func ValidateJidWithLogin(waCli *whatsmeow.Client, jid string) (types.JID, error) { - MustLogin(waCli) - - if config.WhatsappAccountValidation && !IsOnWhatsapp(waCli, jid) { - return types.JID{}, pkgError.InvalidJID(fmt.Sprintf("Phone %s is not on whatsapp", jid)) - } - - return ParseJID(jid) -} - -func InitWaDB() *sqlstore.Container { - // Running Whatsapp - log = waLog.Stdout("Main", config.WhatsappLogLevel, true) - dbLog := waLog.Stdout("Database", config.WhatsappLogLevel, true) - storeContainer, err := sqlstore.New("sqlite3", fmt.Sprintf("file:%s/%s?_foreign_keys=off", config.PathStorages, config.DBName), 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 -} - -func InitWaCLI(storeContainer *sqlstore.Container) *whatsmeow.Client { - device, err := storeContainer.GetFirstDevice() - if err != nil { - log.Errorf("Failed to get device: %v", err) - panic(err) - } - - osName := fmt.Sprintf("%s %s", config.AppOs, config.AppVersion) - store.DeviceProps.PlatformType = &config.AppPlatform - store.DeviceProps.Os = &osName - cli = whatsmeow.NewClient(device, waLog.Stdout("Client", config.WhatsappLogLevel, true)) - cli.EnableAutoReconnect = true - cli.AutoTrustIdentity = true - cli.AddEventHandler(handler) - - return cli -} - -func MustLogin(waCli *whatsmeow.Client) { - if waCli == nil { - panic(pkgError.InternalServerError("Whatsapp client is not initialized")) - } - if !waCli.IsConnected() { - panic(pkgError.ErrNotConnected) - } else if !waCli.IsLoggedIn() { - panic(pkgError.ErrNotLoggedIn) - } -} - -func handler(rawEvt interface{}) { - switch evt := rawEvt.(type) { - case *events.DeleteForMe: - log.Infof("Deleted message %s for %s", evt.MessageID, evt.SenderJID.String()) - 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") - } - } - case *events.PairSuccess: - websocket.Broadcast <- websocket.BroadcastMessage{ - Code: "LOGIN_SUCCESS", - Message: fmt.Sprintf("Successfully pair with %s", evt.ID.String()), - } - case *events.LoggedOut: - websocket.Broadcast <- websocket.BroadcastMessage{ - Code: "LIST_DEVICES", - Result: nil, - } - case *events.Connected, *events.PushNameSetting: - if len(cli.Store.PushName) == 0 { - return - } - - // 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 { - log.Warnf("Failed to send available presence: %v", err) - } else { - 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) - - img := evt.Message.GetImageMessage() - if img != nil { - path, err := ExtractMedia(config.PathStorages, img) - if err != nil { - log.Errorf("Failed to download image: %v", err) - } else { - log.Infof("Image downloaded to %s", path) - } - } - - 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)}) - } - - if config.WhatsappWebhook != "" && - !strings.Contains(evt.Info.SourceString(), "broadcast") && - !isFromMySelf(evt.Info.SourceString()) { - if err := forwardToWebhook(evt); err != nil { - logrus.Error("Failed forward to webhook", err) - } - } - 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) - } - 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) - } - } 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("Wrote history sync to %s", fileName) - _ = file.Close() - case *events.AppState: - log.Debugf("App state event: %+v / %+v", evt.Index, evt.SyncActionValue) - } -} - -func getMessageDigestOrSignature(msg, key []byte) (string, error) { - mac := hmac.New(sha256.New, key) - _, err := mac.Write(msg) - if err != nil { - return "", err - } - return hex.EncodeToString(mac.Sum(nil)), nil -} - -// forwardToWebhook is a helper function to forward event to webhook url -func forwardToWebhook(evt *events.Message) error { - logrus.Info("Forwarding event to webhook:", config.WhatsappWebhook) - client := &http.Client{Timeout: 10 * time.Second} - imageMedia := evt.Message.GetImageMessage() - stickerMedia := evt.Message.GetStickerMessage() - videoMedia := evt.Message.GetVideoMessage() - audioMedia := evt.Message.GetAudioMessage() - documentMedia := evt.Message.GetDocumentMessage() - - var message evtMessage - message.Text = evt.Message.GetConversation() - message.ID = evt.Info.ID - if extendedMessage := evt.Message.ExtendedTextMessage.GetText(); extendedMessage != "" { - message.Text = extendedMessage - message.RepliedId = evt.Message.ExtendedTextMessage.ContextInfo.GetStanzaID() - } - - var quotedmessage any - if evt.Message.ExtendedTextMessage != nil && evt.Message.ExtendedTextMessage.ContextInfo != nil { - if conversation := evt.Message.ExtendedTextMessage.ContextInfo.QuotedMessage.GetConversation(); conversation != "" { - quotedmessage = conversation - } - } - - var forwarded bool - if evt.Message.ExtendedTextMessage != nil && evt.Message.ExtendedTextMessage.ContextInfo != nil { - forwarded = evt.Message.ExtendedTextMessage.ContextInfo.GetIsForwarded() - } - - var waReaction evtReaction - if reactionMessage := evt.Message.ReactionMessage; reactionMessage != nil { - waReaction.Message = reactionMessage.GetText() - waReaction.ID = reactionMessage.GetKey().GetID() - } - - body := map[string]interface{}{ - "audio": audioMedia, - "contact": evt.Message.GetContactMessage(), - "document": documentMedia, - "forwarded": forwarded, - "from": evt.Info.SourceString(), - "image": imageMedia, - "list": evt.Message.GetListMessage(), - "live_location": evt.Message.GetLiveLocationMessage(), - "location": evt.Message.GetLocationMessage(), - "message": message, - "order": evt.Message.GetOrderMessage(), - "pushname": evt.Info.PushName, - "quoted_message": quotedmessage, - "reaction": waReaction, - "sticker": stickerMedia, - "video": videoMedia, - "view_once": evt.Message.GetViewOnceMessage(), - } - - if imageMedia != nil { - path, err := ExtractMedia(config.PathMedia, imageMedia) - if err != nil { - return pkgError.WebhookError(fmt.Sprintf("Failed to download image: %v", err)) - } - body["image"] = path - } - if stickerMedia != nil { - path, err := ExtractMedia(config.PathMedia, stickerMedia) - if err != nil { - return pkgError.WebhookError(fmt.Sprintf("Failed to download sticker: %v", err)) - } - body["sticker"] = path - } - if videoMedia != nil { - path, err := ExtractMedia(config.PathMedia, videoMedia) - if err != nil { - return pkgError.WebhookError(fmt.Sprintf("Failed to download video: %v", err)) - } - body["video"] = path - } - if audioMedia != nil { - path, err := ExtractMedia(config.PathMedia, audioMedia) - if err != nil { - return pkgError.WebhookError(fmt.Sprintf("Failed to download audio: %v", err)) - } - body["audio"] = path - } - if documentMedia != nil { - path, err := ExtractMedia(config.PathMedia, documentMedia) - if err != nil { - return pkgError.WebhookError(fmt.Sprintf("Failed to download document: %v", err)) - } - body["document"] = path - } - - postBody, err := json.Marshal(body) - if err != nil { - return pkgError.WebhookError(fmt.Sprintf("Failed to marshal body: %v", err)) - } - - req, err := http.NewRequest(http.MethodPost, config.WhatsappWebhook, bytes.NewBuffer(postBody)) - if err != nil { - return pkgError.WebhookError(fmt.Sprintf("error when create http object %v", err)) - } - - secretKey := []byte(config.WhatsappWebhookSecret) - signature, err := getMessageDigestOrSignature(postBody, secretKey) - if err != nil { - return pkgError.WebhookError(fmt.Sprintf("error when create signature %v", err)) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Hub-Signature-256", fmt.Sprintf("sha256=%s", signature)) - - if _, err = client.Do(req); err != nil { - return pkgError.WebhookError(fmt.Sprintf("error when submit webhook %v", err)) - } - return nil -} - -// isGroupJid is a helper function to check if the message is from group -func isGroupJid(jid string) bool { - return strings.Contains(jid, "@g.us") -} - -// isFromMySelf is a helper function to check if the message is from my self (logged in account) -func isFromMySelf(jid string) bool { - return extractPhoneNumber(jid) == extractPhoneNumber(cli.Store.ID.String()) -} - -// extractPhoneNumber is a helper function to extract the phone number from a JID -func extractPhoneNumber(jid string) string { - regex := regexp.MustCompile(`\d+`) - // Find all matches of the pattern in the JID - matches := regex.FindAllString(jid, -1) - // The first match should be the phone number - if len(matches) > 0 { - return matches[0] - } - // If no matches are found, return an empty string - return "" -} - -// ExtractMedia is a helper function to extract media from whatsapp -func ExtractMedia(storageLocation string, mediaFile whatsmeow.DownloadableMessage) (extractedMedia ExtractedMedia, err error) { - if mediaFile == nil { - logrus.Info("Skip download because data is nil") - return extractedMedia, nil - } - - data, err := cli.Download(mediaFile) - if err != nil { - return extractedMedia, err - } - - switch media := mediaFile.(type) { - case *waE2E.ImageMessage: - extractedMedia.MimeType = media.GetMimetype() - extractedMedia.Caption = media.GetCaption() - case *waE2E.AudioMessage: - extractedMedia.MimeType = media.GetMimetype() - case *waE2E.VideoMessage: - extractedMedia.MimeType = media.GetMimetype() - extractedMedia.Caption = media.GetCaption() - case *waE2E.StickerMessage: - extractedMedia.MimeType = media.GetMimetype() - case *waE2E.DocumentMessage: - extractedMedia.MimeType = media.GetMimetype() - extractedMedia.Caption = media.GetCaption() - } - - var extension string - if ext, err := mime.ExtensionsByType(extractedMedia.MimeType); err != nil && len(ext) > 0 { - extension = ext[0] - } else if parts := strings.Split(extractedMedia.MimeType, "/"); len(parts) > 1 { - extension = "." + parts[len(parts)-1] - } - - extractedMedia.MediaPath = fmt.Sprintf("%s/%d-%s%s", storageLocation, time.Now().Unix(), uuid.NewString(), extension) - err = os.WriteFile(extractedMedia.MediaPath, data, 0600) - if err != nil { - return extractedMedia, err - } - return extractedMedia, nil -}