Browse Source

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
pull/225/head v4.22.0
Aldino Kemal 1 year ago
committed by GitHub
parent
commit
c2c94d560e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 21
      src/config/settings.go
  2. 20
      src/go.mod
  3. 20
      src/go.sum
  4. 202
      src/pkg/whatsapp/init.go
  5. 252
      src/pkg/whatsapp/utils.go
  6. 166
      src/pkg/whatsapp/webhook.go
  7. 498
      src/pkg/whatsapp/whatsapp.go

21
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
)

20
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

20
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=

202
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)
}
}

252
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
}

166
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))
}

498
src/pkg/whatsapp/whatsapp.go

@ -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
}
Loading…
Cancel
Save