diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml
index 8ecb881..16bc794 100644
--- a/.github/workflows/release-linux.yml
+++ b/.github/workflows/release-linux.yml
@@ -18,7 +18,7 @@ jobs:
- name: Golang Installation
uses: actions/setup-go@v5
with:
- go-version: '1.23'
+ go-version: '1.24'
- name: Golang build
run: |
cd src && go build -o linux-amd64
@@ -38,7 +38,7 @@ jobs:
- name: Golang Installation
uses: actions/setup-go@v5
with:
- go-version: '1.23'
+ go-version: '1.24'
- name: Golang build
run: |
cd src && go build -o linux-arm64
diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml
index 6f47198..bf41482 100644
--- a/.github/workflows/release-mac.yml
+++ b/.github/workflows/release-mac.yml
@@ -18,7 +18,7 @@ jobs:
- name: Golang Installation
uses: actions/setup-go@v5
with:
- go-version: '1.23'
+ go-version: '1.24'
- name: Golang build
run: |
cd src && go build -o darwin-amd64
diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml
index 820183e..104fb9a 100644
--- a/.github/workflows/release-windows.yml
+++ b/.github/workflows/release-windows.yml
@@ -18,7 +18,7 @@ jobs:
- name: Golang Installation
uses: actions/setup-go@v5
with:
- go-version: '1.23'
+ go-version: '1.24'
- name: Golang build
run: |
cd src && go build -o windows-amd64.exe
diff --git a/docker-compose.yml b/docker-compose.yml
index 888a1d0..c49d887 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,7 +8,7 @@ services:
build:
context: .
dockerfile: ./docker/golang.Dockerfile
- restart: 'always'
+ restart: "on-failure"
ports:
- "3000:3000"
env_file:
diff --git a/docker/golang.Dockerfile b/docker/golang.Dockerfile
index 155605e..a8bd640 100644
--- a/docker/golang.Dockerfile
+++ b/docker/golang.Dockerfile
@@ -1,21 +1,21 @@
############################
# STEP 1 build executable binary
############################
-FROM golang:1.23-alpine3.20 AS builder
+FROM golang:1.24-alpine3.20 AS builder
RUN apk update && apk add --no-cache gcc musl-dev gcompat
WORKDIR /whatsapp
COPY ./src .
# Fetch dependencies.
RUN go mod download
-# Build the binary.
-RUN go build -o /app/whatsapp
+# Build the binary with optimizations
+RUN go build -a -ldflags="-w -s" -o /app/whatsapp
#############################
## STEP 2 build a smaller image
#############################
FROM alpine:3.20
-RUN apk update && apk add --no-cache ffmpeg
+RUN apk add --no-cache ffmpeg
WORKDIR /app
# Copy compiled from builder.
COPY --from=builder /app/whatsapp /app/whatsapp
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index cdcd0ed..2a9d583 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1,7 +1,7 @@
openapi: "3.0.0"
info:
title: WhatsApp API MultiDevice
- version: 5.1.0
+ version: 5.2.0
description: This API is used for sending whatsapp via API
servers:
- url: http://localhost:3000
@@ -282,6 +282,26 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorInternalServer'
+ /user/my/contacts:
+ get:
+ operationId: userMyContacts
+ tags:
+ - user
+ summary: Get list of user contacts
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MyListContactsResponse'
+ '500':
+ description: Internal Server Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorInternalServer'
+
/send/message:
post:
operationId: sendMessage
@@ -1646,6 +1666,31 @@ components:
role:
type: string
example: "subscriber"
+ MyListContactsResponse:
+ type: object
+ properties:
+ code:
+ type: string
+ example: "SUCCESS"
+ message:
+ type: string
+ example: "Success get list contacts"
+ results:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/MyListContacts'
+ MyListContacts:
+ type: object
+ properties:
+ jid:
+ type: string
+ example: "628123123123123@s.whatsapp.net"
+ name:
+ type: string
+ example: "Aldino Kemal"
GroupResponse:
type: object
properties:
diff --git a/readme.md b/readme.md
index 5955881..87f1551 100644
--- a/readme.md
+++ b/readme.md
@@ -38,7 +38,8 @@ Now that we support ARM64 for Linux:
- `--webhook="http://yourwebhook.site/handler"`, or you can simplify
- `-w="http://yourwebhook.site/handler"`
- Webhook Secret
- Our webhook will be sent to you with an HMAC header and a sha256 default key `secret`.
+ Our webhook will be sent to you with an HMAC header and a sha256 default key `secret`.
+
You may modify this by using the option below:
- `--webhook-secret="secret"`
@@ -53,6 +54,7 @@ You can configure the application using either command-line flags (shown above)
### Environment Variables
To use environment variables:
+
1. Copy `.env.example` to `.env` in your project root
2. Modify the values in `.env` according to your needs
3. Or set the same variables as system environment variables
@@ -187,6 +189,7 @@ You can fork or edit this source code !
| ✅ | User My Groups | GET | /user/my/groups |
| ✅ | User My Newsletter | GET | /user/my/newsletters |
| ✅ | User My Privacy Setting | GET | /user/my/privacy |
+| ✅ | User My Contacts | GET | /user/my/contacts |
| ✅ | Send Message | POST | /send/message |
| ✅ | Send Image | POST | /send/image |
| ✅ | Send Audio | POST | /send/audio |
diff --git a/src/cmd/root.go b/src/cmd/root.go
index ceb72df..cfc84e1 100644
--- a/src/cmd/root.go
+++ b/src/cmd/root.go
@@ -3,7 +3,8 @@ package cmd
import (
"embed"
"fmt"
- "go.mau.fi/whatsmeow/store/sqlstore"
+ pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
+ waLog "go.mau.fi/whatsmeow/util/log"
"log"
"net/http"
"os"
@@ -240,12 +241,21 @@ func runRest(_ *cobra.Command, _ []string) {
}))
}
- db := whatsapp.InitWaDB(config.DBURI)
- var db_keys *sqlstore.Container
- if config.DBKeysURI != "" {
- db_keys = whatsapp.InitWaDB(config.DBKeysURI)
+ waLogger := waLog.Stdout("Main", config.WhatsappLogLevel, true)
+ dbLogger := waLog.Stdout("Database", config.WhatsappLogLevel, true)
+
+ db, err := whatsapp.InitDatabase(config.DBURI, dbLogger)
+ if err != nil {
+ waLogger.Errorf("Database initialization error: %v", err)
+ panic(pkgError.InternalServerError(fmt.Sprintf("Database initialization error: %v", err)))
+ }
+
+ dbKeys, err := whatsapp.InitDatabase(config.DBURI, dbLogger)
+ if err != nil {
+ waLogger.Errorf("Database initialization error: %v", err)
+ panic(pkgError.InternalServerError(fmt.Sprintf("Database initialization error: %v", err)))
}
- cli := whatsapp.InitWaCLI(db, db_keys)
+ cli := whatsapp.InitWaCLI(db, dbKeys)
// Service
appService := services.NewAppService(cli, db)
diff --git a/src/config/settings.go b/src/config/settings.go
index 489c5c2..badc068 100644
--- a/src/config/settings.go
+++ b/src/config/settings.go
@@ -5,7 +5,7 @@ import (
)
var (
- AppVersion = "v5.2.0"
+ AppVersion = "v5.3.0"
AppPort = "3000"
AppDebug = false
AppOs = "AldinoKemal"
diff --git a/src/domains/user/account.go b/src/domains/user/account.go
index 039c373..fba3b0b 100644
--- a/src/domains/user/account.go
+++ b/src/domains/user/account.go
@@ -1,8 +1,9 @@
package user
import (
- "go.mau.fi/whatsmeow/types"
"mime/multipart"
+
+ "go.mau.fi/whatsmeow/types"
)
type InfoRequest struct {
@@ -59,3 +60,12 @@ type MyListNewsletterResponse struct {
type ChangeAvatarRequest struct {
Avatar *multipart.FileHeader `json:"avatar" form:"avatar"`
}
+
+type MyListContactsResponse struct {
+ Data []MyListContactsResponseData `json:"data"`
+}
+
+type MyListContactsResponseData struct {
+ JID types.JID `json:"jid"`
+ Name string `json:"name"`
+}
diff --git a/src/domains/user/user.go b/src/domains/user/user.go
index 7d47959..0b46309 100644
--- a/src/domains/user/user.go
+++ b/src/domains/user/user.go
@@ -11,4 +11,5 @@ type IUserService interface {
MyListGroups(ctx context.Context) (response MyListGroupsResponse, err error)
MyListNewsletter(ctx context.Context) (response MyListNewsletterResponse, err error)
MyPrivacySetting(ctx context.Context) (response MyPrivacySettingResponse, err error)
+ MyListContacts(ctx context.Context) (response MyListContactsResponse, err error)
}
diff --git a/src/go.mod b/src/go.mod
index edba4bf..b657b16 100644
--- a/src/go.mod
+++ b/src/go.mod
@@ -1,8 +1,6 @@
module github.com/aldinokemal/go-whatsapp-web-multidevice
-go 1.23.0
-
-toolchain go1.23.6
+go 1.24.0
require (
github.com/PuerkitoBio/goquery v1.10.2
@@ -22,7 +20,7 @@ require (
github.com/stretchr/testify v1.10.0
github.com/valyala/fasthttp v1.59.0
go.mau.fi/libsignal v0.1.2
- go.mau.fi/whatsmeow v0.0.0-20250216151842-97deed8f95f7
+ go.mau.fi/whatsmeow v0.0.0-20250225112721-b7530f3a5056
google.golang.org/protobuf v1.36.5
)
@@ -60,7 +58,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.mau.fi/util v0.8.5 // indirect
go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/crypto v0.33.0 // indirect
+ golang.org/x/crypto v0.35.0 // indirect
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
diff --git a/src/go.sum b/src/go.sum
index 5985378..9c88314 100644
--- a/src/go.sum
+++ b/src/go.sum
@@ -126,8 +126,8 @@ go.mau.fi/libsignal v0.1.2 h1:Vs16DXWxSKyzVtI+EEXLCSy5pVWzzCzp/2eqFGvLyP0=
go.mau.fi/libsignal v0.1.2/go.mod h1:JpnLSSJptn/s1sv7I56uEMywvz8x4YzxeF5OzdPb6PE=
go.mau.fi/util v0.8.5 h1:PwCAAtcfK0XxZ4sdErJyfBMkTEWoQU33aB7QqDDzQRI=
go.mau.fi/util v0.8.5/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
-go.mau.fi/whatsmeow v0.0.0-20250216151842-97deed8f95f7 h1:K5Vgr6PUguay1zaiRwsd1St+1PUSAxk56CI71jJitXA=
-go.mau.fi/whatsmeow v0.0.0-20250216151842-97deed8f95f7/go.mod h1:6hRrUtDWI2wTRClOd6m17GwrFE2a8/p5R4pjJsIVn+U=
+go.mau.fi/whatsmeow v0.0.0-20250225112721-b7530f3a5056 h1:1JQUOpYXhFSEQgXMEWD/ZH38FrIe5i1yjxSBwa0aN/Q=
+go.mau.fi/whatsmeow v0.0.0-20250225112721-b7530f3a5056/go.mod h1:6hRrUtDWI2wTRClOd6m17GwrFE2a8/p5R4pjJsIVn+U=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -136,8 +136,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
-golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
+golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4=
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
diff --git a/src/internal/rest/message.go b/src/internal/rest/message.go
index 8ec7ad1..8d6d9fc 100644
--- a/src/internal/rest/message.go
+++ b/src/internal/rest/message.go
@@ -1,7 +1,6 @@
package rest
import (
- "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message"
domainMessage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/whatsapp"
@@ -82,7 +81,7 @@ func (controller *Message) UpdateMessage(c *fiber.Ctx) error {
}
func (controller *Message) ReactMessage(c *fiber.Ctx) error {
- var request message.ReactionRequest
+ var request domainMessage.ReactionRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
@@ -101,7 +100,7 @@ func (controller *Message) ReactMessage(c *fiber.Ctx) error {
}
func (controller *Message) MarkAsRead(c *fiber.Ctx) error {
- var request message.MarkAsReadRequest
+ var request domainMessage.MarkAsReadRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
diff --git a/src/internal/rest/user.go b/src/internal/rest/user.go
index c357d6f..10596a5 100644
--- a/src/internal/rest/user.go
+++ b/src/internal/rest/user.go
@@ -19,6 +19,7 @@ func InitRestUser(app *fiber.App, service domainUser.IUserService) User {
app.Get("/user/my/privacy", rest.UserMyPrivacySetting)
app.Get("/user/my/groups", rest.UserMyListGroups)
app.Get("/user/my/newsletters", rest.UserMyListNewsletter)
+ app.Get("/user/my/contacts", rest.UserMyListContacts)
return rest
}
@@ -112,3 +113,15 @@ func (controller *User) UserMyListNewsletter(c *fiber.Ctx) error {
Results: response,
})
}
+
+func (controller *User) UserMyListContacts(c *fiber.Ctx) error {
+ response, err := controller.Service.MyListContacts(c.UserContext())
+ utils.PanicIfNeeded(err)
+
+ return c.JSON(utils.ResponseData{
+ Status: 200,
+ Code: "SUCCESS",
+ Message: "Success get list contacts",
+ Results: response,
+ })
+}
diff --git a/src/internal/websocket/websocket.go b/src/internal/websocket/websocket.go
index 15f337c..4c3fe3a 100644
--- a/src/internal/websocket/websocket.go
+++ b/src/internal/websocket/websocket.go
@@ -3,102 +3,111 @@ package websocket
import (
"context"
"encoding/json"
+ "log"
+
domainApp "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/app"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/websocket/v2"
- "log"
)
-type client struct{} // Add more data to this type if needed
+type client struct{}
+
type BroadcastMessage struct {
Code string `json:"code"`
Message string `json:"message"`
Result any `json:"result"`
}
-var Clients = make(map[*websocket.Conn]client) // Note: although large maps with pointer-like types (e.g. strings) as keys are slow, using pointers themselves as keys is acceptable and fast
-var Register = make(chan *websocket.Conn)
-var Broadcast = make(chan BroadcastMessage)
-var Unregister = make(chan *websocket.Conn)
+var (
+ Clients = make(map[*websocket.Conn]client)
+ Register = make(chan *websocket.Conn)
+ Broadcast = make(chan BroadcastMessage)
+ Unregister = make(chan *websocket.Conn)
+)
+
+func handleRegister(conn *websocket.Conn) {
+ Clients[conn] = client{}
+ log.Println("connection registered")
+}
+
+func handleUnregister(conn *websocket.Conn) {
+ delete(Clients, conn)
+ log.Println("connection unregistered")
+}
+
+func broadcastMessage(message BroadcastMessage) {
+ marshalMessage, err := json.Marshal(message)
+ if err != nil {
+ log.Println("marshal error:", err)
+ return
+ }
+
+ for conn := range Clients {
+ if err := conn.WriteMessage(websocket.TextMessage, marshalMessage); err != nil {
+ log.Println("write error:", err)
+ closeConnection(conn)
+ }
+ }
+}
+
+func closeConnection(conn *websocket.Conn) {
+ if err := conn.WriteMessage(websocket.CloseMessage, []byte{}); err != nil {
+ log.Println("write close message error:", err)
+ }
+ if err := conn.Close(); err != nil {
+ log.Println("close connection error:", err)
+ }
+ delete(Clients, conn)
+}
func RunHub() {
for {
select {
- case connection := <-Register:
- Clients[connection] = client{}
- log.Println("connection registered")
+ case conn := <-Register:
+ handleRegister(conn)
+
+ case conn := <-Unregister:
+ handleUnregister(conn)
case message := <-Broadcast:
log.Println("message received:", message)
- marshalMessage, err := json.Marshal(message)
- if err != nil {
- log.Println("write error:", err)
- return
- }
-
- // Send the message to all clients
- for connection := range Clients {
- if err := connection.WriteMessage(websocket.TextMessage, marshalMessage); err != nil {
- log.Println("write error:", err)
-
- err := connection.WriteMessage(websocket.CloseMessage, []byte{})
- if err != nil {
- log.Println("write message close error:", err)
- return
- }
- err = connection.Close()
- if err != nil {
- log.Println("close error:", err)
- return
- }
- delete(Clients, connection)
- }
- }
-
- case connection := <-Unregister:
- // Remove the client from the hub
- delete(Clients, connection)
-
- log.Println("connection unregistered")
+ broadcastMessage(message)
}
}
}
func RegisterRoutes(app *fiber.App, service domainApp.IAppService) {
app.Use("/ws", func(c *fiber.Ctx) error {
- if websocket.IsWebSocketUpgrade(c) { // Returns true if the client requested upgrade to the WebSocket protocol
+ if websocket.IsWebSocketUpgrade(c) {
return c.Next()
}
return c.SendStatus(fiber.StatusUpgradeRequired)
})
- app.Get("/ws", websocket.New(func(c *websocket.Conn) {
- // When the function returns, unregister the client and close the connection
+ app.Get("/ws", websocket.New(func(conn *websocket.Conn) {
defer func() {
- Unregister <- c
- _ = c.Close()
+ Unregister <- conn
+ _ = conn.Close()
}()
- // Register the client
- Register <- c
+ Register <- conn
for {
- messageType, message, err := c.ReadMessage()
+ messageType, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Println("read error:", err)
}
- return // Calls the deferred function, i.e. closes the connection on error
+ return
}
if messageType == websocket.TextMessage {
- // Broadcast the received message
var messageData BroadcastMessage
- err := json.Unmarshal(message, &messageData)
- if err != nil {
- log.Println("error unmarshal message:", err)
+ if err := json.Unmarshal(message, &messageData); err != nil {
+ log.Println("unmarshal error:", err)
return
}
+
if messageData.Code == "FETCH_DEVICES" {
devices, _ := service.FetchDevices(context.Background())
Broadcast <- BroadcastMessage{
@@ -107,9 +116,8 @@ func RegisterRoutes(app *fiber.App, service domainApp.IAppService) {
Result: devices,
}
}
-
} else {
- log.Println("websocket message received of type", messageType)
+ log.Println("unsupported message type:", messageType)
}
}
}))
diff --git a/src/pkg/utils/general.go b/src/pkg/utils/general.go
index 90c8b80..99faff9 100644
--- a/src/pkg/utils/general.go
+++ b/src/pkg/utils/general.go
@@ -69,20 +69,22 @@ type Metadata struct {
Description string
Image string
ImageThumb []byte
+ Height *uint32
+ Width *uint32
}
-func GetMetaDataFromURL(url string) (meta Metadata) {
+func GetMetaDataFromURL(url string) (meta Metadata, err error) {
// Send an HTTP GET request to the website
response, err := http.Get(url)
if err != nil {
- log.Fatal(err)
+ return meta, err
}
defer response.Body.Close()
// Parse the HTML document
document, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
- log.Fatal(err)
+ return meta, err
}
document.Find("meta[name='description']").Each(func(index int, element *goquery.Selection) {
@@ -98,6 +100,22 @@ func GetMetaDataFromURL(url string) (meta Metadata) {
meta.Image, _ = element.Attr("content")
})
+ document.Find("meta[property='og:image:width']").Each(func(index int, element *goquery.Selection) {
+ if content, exists := element.Attr("content"); exists {
+ width, _ := strconv.Atoi(content)
+ widthUint32 := uint32(width)
+ meta.Width = &widthUint32
+ }
+ })
+
+ document.Find("meta[property='og:image:height']").Each(func(index int, element *goquery.Selection) {
+ if content, exists := element.Attr("content"); exists {
+ height, _ := strconv.Atoi(content)
+ heightUint32 := uint32(height)
+ meta.Height = &heightUint32
+ }
+ })
+
// If an og:image is found, download it and store its content in ImageThumb
if meta.Image != "" {
imageResponse, err := http.Get(meta.Image)
@@ -114,7 +132,7 @@ func GetMetaDataFromURL(url string) (meta Metadata) {
}
}
- return meta
+ return meta, nil
}
// ContainsMention is checking if message contains mention, then return only mention without @
diff --git a/src/pkg/utils/general_test.go b/src/pkg/utils/general_test.go
index b16c2bf..8883454 100644
--- a/src/pkg/utils/general_test.go
+++ b/src/pkg/utils/general_test.go
@@ -93,7 +93,8 @@ func (suite *UtilsTestSuite) TestGetMetaDataFromURL() {
}))
defer server.Close() // Ensure the server is closed when the test ends
- meta := utils.GetMetaDataFromURL(server.URL)
+ meta, err := utils.GetMetaDataFromURL(server.URL)
+ assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "Test Title", meta.Title)
assert.Equal(suite.T(), "Test Description", meta.Description)
assert.Equal(suite.T(), "http://example.com/image.jpg", meta.Image)
diff --git a/src/pkg/whatsapp/init.go b/src/pkg/whatsapp/init.go
index 28f9518..479759f 100644
--- a/src/pkg/whatsapp/init.go
+++ b/src/pkg/whatsapp/init.go
@@ -11,7 +11,6 @@ import (
"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/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
"github.com/sirupsen/logrus"
"go.mau.fi/whatsmeow"
@@ -25,12 +24,7 @@ import (
"google.golang.org/protobuf/proto"
)
-var (
- cli *whatsmeow.Client
- log waLog.Logger
- historySyncID int32
- startupTime = time.Now().Unix()
-)
+// Type definitions
type ExtractedMedia struct {
MediaPath string `json:"media_path"`
@@ -50,29 +44,26 @@ type evtMessage struct {
QuotedMessage string `json:"quoted_message,omitempty"`
}
-func InitWaDB(db_uri string) *sqlstore.Container {
- // Running Whatsapp
- log = waLog.Stdout("Main", config.WhatsappLogLevel, true)
- dbLog := waLog.Stdout("Database", config.WhatsappLogLevel, true)
+// Global variables
+var (
+ cli *whatsmeow.Client
+ log waLog.Logger
+ historySyncID int32
+ startupTime = time.Now().Unix()
+)
- var storeContainer *sqlstore.Container
- var err error
- if strings.HasPrefix(db_uri, "file:") {
- storeContainer, err = sqlstore.New("sqlite3", db_uri, dbLog)
- } else if strings.HasPrefix(db_uri, "postgres:") {
- storeContainer, err = sqlstore.New("postgres", db_uri, dbLog)
- } else {
- log.Errorf("Unknown database type: %s", db_uri)
- panic(pkgError.InternalServerError(fmt.Sprintf("Unknown database type: %s. Currently only sqlite3(file:) and postgres are supported", db_uri)))
+// InitDatabase creates and returns a database store container based on the configured URI
+func InitDatabase(dbUri string, dbLog waLog.Logger) (*sqlstore.Container, error) {
+ if strings.HasPrefix(dbUri, "file:") {
+ return sqlstore.New("sqlite3", dbUri, dbLog)
+ } else if strings.HasPrefix(dbUri, "postgres:") {
+ return sqlstore.New("postgres", dbUri, dbLog)
}
- if err != nil {
- log.Errorf("Failed to connect to database: %v", err)
- panic(pkgError.InternalServerError(fmt.Sprintf("Failed to connect to database: %v", err)))
- }
- return storeContainer
+ return nil, fmt.Errorf("unknown database type: %s. Currently only sqlite3(file:) and postgres are supported", dbUri)
}
+// InitWaCLI initializes the WhatsApp client
func InitWaCLI(storeContainer, keysStoreContainer *sqlstore.Container) *whatsmeow.Client {
device, err := storeContainer.GetFirstDevice()
if err != nil {
@@ -85,10 +76,12 @@ func InitWaCLI(storeContainer, keysStoreContainer *sqlstore.Container) *whatsmeo
panic("No device found")
}
+ // Configure device properties
osName := fmt.Sprintf("%s %s", config.AppOs, config.AppVersion)
store.DeviceProps.PlatformType = &config.AppPlatform
store.DeviceProps.Os = &osName
+ // Configure a separated database for accelerating encryption caching
if keysStoreContainer != nil && device.ID != nil {
innerStore := sqlstore.NewSQLStore(keysStoreContainer, *device.ID)
device.Identities = innerStore
@@ -99,6 +92,7 @@ func InitWaCLI(storeContainer, keysStoreContainer *sqlstore.Container) *whatsmeo
device.PrivacyTokens = innerStore
}
+ // Create and configure the client
cli = whatsmeow.NewClient(device, waLog.Stdout("Client", config.WhatsappLogLevel, true))
cli.EnableAutoReconnect = true
cli.AutoTrustIdentity = true
@@ -107,120 +101,205 @@ func InitWaCLI(storeContainer, keysStoreContainer *sqlstore.Container) *whatsmeo
return cli
}
+// handler is the main event handler for WhatsApp events
func handler(rawEvt interface{}) {
switch evt := rawEvt.(type) {
case *events.DeleteForMe:
- log.Infof("Deleted message %s for %s", evt.MessageID, evt.SenderJID.String())
+ handleDeleteForMe(evt)
case *events.AppStateSyncComplete:
- if len(cli.Store.PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
- err := cli.SendPresence(types.PresenceAvailable)
- if err != nil {
- log.Warnf("Failed to send available presence: %v", err)
- } else {
- log.Infof("Marked self as available")
- }
- }
+ handleAppStateSyncComplete(evt)
case *events.PairSuccess:
- websocket.Broadcast <- websocket.BroadcastMessage{
- Code: "LOGIN_SUCCESS",
- Message: fmt.Sprintf("Successfully pair with %s", evt.ID.String()),
- }
+ handlePairSuccess(evt)
case *events.LoggedOut:
- websocket.Broadcast <- websocket.BroadcastMessage{
- Code: "LIST_DEVICES",
- Result: nil,
- }
+ handleLoggedOut()
case *events.Connected, *events.PushNameSetting:
- if len(cli.Store.PushName) == 0 {
- return
- }
+ handleConnectionEvents()
+ case *events.StreamReplaced:
+ handleStreamReplaced()
+ case *events.Message:
+ handleMessage(evt)
+ case *events.Receipt:
+ handleReceipt(evt)
+ case *events.Presence:
+ handlePresence(evt)
+ case *events.HistorySync:
+ handleHistorySync(evt)
+ case *events.AppState:
+ handleAppState(evt)
+ }
+}
+
+// Event handler functions
+
+func handleDeleteForMe(evt *events.DeleteForMe) {
+ log.Infof("Deleted message %s for %s", evt.MessageID, evt.SenderJID.String())
+}
- // Send presence available when connecting and when the pushname is changed.
- // This makes sure that outgoing messages always have the right pushname.
- err := cli.SendPresence(types.PresenceAvailable)
- if err != nil {
+func handleAppStateSyncComplete(evt *events.AppStateSyncComplete) {
+ if len(cli.Store.PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
+ if err := cli.SendPresence(types.PresenceAvailable); err != nil {
log.Warnf("Failed to send available presence: %v", err)
} 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)
- message := ExtractMessageText(evt)
- utils.RecordMessage(evt.Info.ID, evt.Info.Sender.String(), message)
+func handlePairSuccess(evt *events.PairSuccess) {
+ websocket.Broadcast <- websocket.BroadcastMessage{
+ Code: "LOGIN_SUCCESS",
+ Message: fmt.Sprintf("Successfully pair with %s", evt.ID.String()),
+ }
+}
- if img := evt.Message.GetImageMessage(); img != nil {
- if path, err := ExtractMedia(config.PathStorages, img); err != nil {
- log.Errorf("Failed to download image: %v", err)
- } else {
- log.Infof("Image downloaded to %s", path)
- }
- }
+func handleLoggedOut() {
+ websocket.Broadcast <- websocket.BroadcastMessage{
+ Code: "LIST_DEVICES",
+ Result: nil,
+ }
+}
- if config.WhatsappAutoReplyMessage != "" &&
- !isGroupJid(evt.Info.Chat.String()) &&
- !strings.Contains(evt.Info.SourceString(), "broadcast") {
- _, _ = cli.SendMessage(context.Background(), evt.Info.Sender, &waE2E.Message{Conversation: proto.String(config.WhatsappAutoReplyMessage)})
- }
+func handleConnectionEvents() {
+ if len(cli.Store.PushName) == 0 {
+ return
+ }
- if len(config.WhatsappWebhook) > 0 &&
- !strings.Contains(evt.Info.SourceString(), "broadcast") &&
- !isFromMySelf(evt.Info.SourceString()) {
- go func(evt *events.Message) {
- if err := forwardToWebhook(evt); err != nil {
- logrus.Error("Failed forward to webhook: ", err)
- }
- }(evt)
- }
- case *events.Receipt:
- if evt.Type == types.ReceiptTypeRead || evt.Type == types.ReceiptTypeReadSelf {
- log.Infof("%v was read by %s at %s", evt.MessageIDs, evt.SourceString(), evt.Timestamp)
- } else if evt.Type == types.ReceiptTypeDelivered {
- log.Infof("%s was delivered to %s at %s", evt.MessageIDs[0], evt.SourceString(), evt.Timestamp)
+ // Send presence available when connecting and when the pushname is changed.
+ // This makes sure that outgoing messages always have the right pushname.
+ if err := cli.SendPresence(types.PresenceAvailable); err != nil {
+ log.Warnf("Failed to send available presence: %v", err)
+ } else {
+ log.Infof("Marked self as available")
+ }
+}
+
+func handleStreamReplaced() {
+ os.Exit(0)
+}
+
+func handleMessage(evt *events.Message) {
+ // Log message metadata
+ metaParts := buildMessageMetaParts(evt)
+ log.Infof("Received message %s from %s (%s): %+v",
+ evt.Info.ID,
+ evt.Info.SourceString(),
+ strings.Join(metaParts, ", "),
+ evt.Message,
+ )
+
+ // Record the message
+ message := ExtractMessageText(evt)
+ utils.RecordMessage(evt.Info.ID, evt.Info.Sender.String(), message)
+
+ // Handle image message if present
+ handleImageMessage(evt)
+
+ // Handle auto-reply if configured
+ handleAutoReply(evt)
+
+ // Forward to webhook if configured
+ handleWebhookForward(evt)
+}
+
+func buildMessageMetaParts(evt *events.Message) []string {
+ metaParts := []string{
+ fmt.Sprintf("pushname: %s", evt.Info.PushName),
+ fmt.Sprintf("timestamp: %s", evt.Info.Timestamp),
+ }
+ if evt.Info.Type != "" {
+ metaParts = append(metaParts, fmt.Sprintf("type: %s", evt.Info.Type))
+ }
+ if evt.Info.Category != "" {
+ metaParts = append(metaParts, fmt.Sprintf("category: %s", evt.Info.Category))
+ }
+ if evt.IsViewOnce {
+ metaParts = append(metaParts, "view once")
+ }
+ return metaParts
+}
+
+func handleImageMessage(evt *events.Message) {
+ if img := evt.Message.GetImageMessage(); img != nil {
+ if path, err := ExtractMedia(config.PathStorages, img); err != nil {
+ log.Errorf("Failed to download image: %v", err)
+ } else {
+ log.Infof("Image downloaded to %s", path)
}
- case *events.Presence:
- if evt.Unavailable {
- if evt.LastSeen.IsZero() {
- log.Infof("%s is now offline", evt.From)
- } else {
- log.Infof("%s is now offline (last seen: %s)", evt.From, evt.LastSeen)
+ }
+}
+
+func handleAutoReply(evt *events.Message) {
+ if config.WhatsappAutoReplyMessage != "" &&
+ !isGroupJid(evt.Info.Chat.String()) &&
+ !evt.Info.IsIncomingBroadcast() &&
+ evt.Message.GetExtendedTextMessage().GetText() != "" {
+ _, _ = cli.SendMessage(
+ context.Background(),
+ FormatJID(evt.Info.Sender.String()),
+ &waE2E.Message{Conversation: proto.String(config.WhatsappAutoReplyMessage)},
+ )
+ }
+}
+
+func handleWebhookForward(evt *events.Message) {
+ if len(config.WhatsappWebhook) > 0 &&
+ !strings.Contains(evt.Info.SourceString(), "broadcast") &&
+ !isFromMySelf(evt.Info.SourceString()) {
+ go func(evt *events.Message) {
+ if err := forwardToWebhook(evt); err != nil {
+ logrus.Error("Failed forward to webhook: ", err)
}
+ }(evt)
+ }
+}
+
+func handleReceipt(evt *events.Receipt) {
+ if evt.Type == types.ReceiptTypeRead || evt.Type == types.ReceiptTypeReadSelf {
+ log.Infof("%v was read by %s at %s", evt.MessageIDs, evt.SourceString(), evt.Timestamp)
+ } else if evt.Type == types.ReceiptTypeDelivered {
+ log.Infof("%s was delivered to %s at %s", evt.MessageIDs[0], evt.SourceString(), evt.Timestamp)
+ }
+}
+
+func handlePresence(evt *events.Presence) {
+ if evt.Unavailable {
+ if evt.LastSeen.IsZero() {
+ log.Infof("%s is now offline", evt.From)
} else {
- log.Infof("%s is now online", evt.From)
- }
- case *events.HistorySync:
- id := atomic.AddInt32(&historySyncID, 1)
- fileName := fmt.Sprintf("%s/history-%d-%s-%d-%s.json", config.PathStorages, startupTime, cli.Store.ID.String(), id, evt.Data.SyncType.String())
- file, err := os.OpenFile(fileName, os.O_WRONLY|os.O_CREATE, 0600)
- if err != nil {
- log.Errorf("Failed to open file to write history sync: %v", err)
- return
- }
- enc := json.NewEncoder(file)
- enc.SetIndent("", " ")
- err = enc.Encode(evt.Data)
- if err != nil {
- log.Errorf("Failed to write history sync: %v", err)
- return
+ log.Infof("%s is now offline (last seen: %s)", evt.From, evt.LastSeen)
}
- log.Infof("Wrote history sync to %s", fileName)
- _ = file.Close()
- case *events.AppState:
- log.Debugf("App state event: %+v / %+v", evt.Index, evt.SyncActionValue)
+ } else {
+ log.Infof("%s is now online", evt.From)
+ }
+}
+
+func handleHistorySync(evt *events.HistorySync) {
+ id := atomic.AddInt32(&historySyncID, 1)
+ fileName := fmt.Sprintf("%s/history-%d-%s-%d-%s.json",
+ config.PathStorages,
+ startupTime,
+ cli.Store.ID.String(),
+ id,
+ evt.Data.SyncType.String(),
+ )
+
+ file, err := os.OpenFile(fileName, os.O_WRONLY|os.O_CREATE, 0600)
+ if err != nil {
+ log.Errorf("Failed to open file to write history sync: %v", err)
+ return
+ }
+ defer file.Close()
+
+ enc := json.NewEncoder(file)
+ enc.SetIndent("", " ")
+ if err = enc.Encode(evt.Data); err != nil {
+ log.Errorf("Failed to write history sync: %v", err)
+ return
}
+
+ log.Infof("Wrote history sync to %s", fileName)
+}
+
+func handleAppState(evt *events.AppState) {
+ log.Debugf("App state event: %+v / %+v", evt.Index, evt.SyncActionValue)
}
diff --git a/src/pkg/whatsapp/utils.go b/src/pkg/whatsapp/utils.go
index 5f3b9a6..a4e84ca 100644
--- a/src/pkg/whatsapp/utils.go
+++ b/src/pkg/whatsapp/utils.go
@@ -177,6 +177,18 @@ func MustLogin(waCli *whatsmeow.Client) {
}
}
+func FormatJID(jid string) types.JID {
+ // Remove any :number suffix if present
+ if idx := strings.LastIndex(jid, ":"); idx != -1 && strings.Contains(jid, "@s.whatsapp.net") {
+ jid = jid[:idx] + jid[strings.Index(jid, "@s.whatsapp.net"):]
+ }
+ formattedJID, err := ParseJID(jid)
+ if err != nil {
+ return types.JID{}
+ }
+ return formattedJID
+}
+
// isGroupJid is a helper function to check if the message is from group
func isGroupJid(jid string) bool {
return strings.Contains(jid, "@g.us")
diff --git a/src/pkg/whatsapp/webhook.go b/src/pkg/whatsapp/webhook.go
index 307b79d..5a1dc24 100644
--- a/src/pkg/whatsapp/webhook.go
+++ b/src/pkg/whatsapp/webhook.go
@@ -41,7 +41,7 @@ func createPayload(evt *events.Message) (map[string]interface{}, error) {
if from := evt.Info.SourceString(); from != "" {
body["from"] = from
}
- if message.Text != "" {
+ if message.ID != "" {
body["message"] = message
}
if pushname := evt.Info.PushName; pushname != "" {
diff --git a/src/services/send.go b/src/services/send.go
index 0e82961..9a9afa2 100644
--- a/src/services/send.go
+++ b/src/services/send.go
@@ -430,14 +430,19 @@ func (service serviceSend) SendLink(ctx context.Context, request domainSend.Link
return response, err
}
- getMetaDataFromURL := utils.GetMetaDataFromURL(request.Link)
+ getMetaDataFromURL, err := utils.GetMetaDataFromURL(request.Link)
+ if err != nil {
+ return response, err
+ }
msg := &waE2E.Message{ExtendedTextMessage: &waE2E.ExtendedTextMessage{
- Text: proto.String(fmt.Sprintf("%s\n%s", request.Caption, request.Link)),
- Title: proto.String(getMetaDataFromURL.Title),
- MatchedText: proto.String(request.Link),
- Description: proto.String(getMetaDataFromURL.Description),
- JPEGThumbnail: getMetaDataFromURL.ImageThumb,
+ Text: proto.String(fmt.Sprintf("%s\n%s", request.Caption, request.Link)),
+ Title: proto.String(getMetaDataFromURL.Title),
+ MatchedText: proto.String(request.Link),
+ Description: proto.String(getMetaDataFromURL.Description),
+ JPEGThumbnail: getMetaDataFromURL.ImageThumb,
+ ThumbnailHeight: getMetaDataFromURL.Height,
+ ThumbnailWidth: getMetaDataFromURL.Width,
}}
content := "🔗 " + request.Link
diff --git a/src/services/user.go b/src/services/user.go
index b6636bb..e1d0c94 100644
--- a/src/services/user.go
+++ b/src/services/user.go
@@ -160,6 +160,24 @@ func (service userService) MyPrivacySetting(_ context.Context) (response domainU
return response, nil
}
+func (service userService) MyListContacts(ctx context.Context) (response domainUser.MyListContactsResponse, err error) {
+ whatsapp.MustLogin(service.WaCli)
+
+ contacts, err := service.WaCli.Store.Contacts.GetAllContacts()
+ if err != nil {
+ return
+ }
+
+ for jid, contact := range contacts {
+ response.Data = append(response.Data, domainUser.MyListContactsResponseData{
+ JID: jid,
+ Name: contact.FullName,
+ })
+ }
+
+ return response, nil
+}
+
func (service userService) ChangeAvatar(ctx context.Context, request domainUser.ChangeAvatarRequest) (err error) {
whatsapp.MustLogin(service.WaCli)
diff --git a/src/views/components/AccountAvatar.js b/src/views/components/AccountAvatar.js
index 9d7b292..9a52403 100644
--- a/src/views/components/AccountAvatar.js
+++ b/src/views/components/AccountAvatar.js
@@ -65,7 +65,7 @@ export default {
}
},
template: `
-
+
Account
diff --git a/src/views/components/AccountChangeAvatar.js b/src/views/components/AccountChangeAvatar.js
index 6c8596b..7c109cc 100644
--- a/src/views/components/AccountChangeAvatar.js
+++ b/src/views/components/AccountChangeAvatar.js
@@ -63,7 +63,7 @@ export default {
}
},
template: `
-
+
Account
diff --git a/src/views/components/AccountContact.js b/src/views/components/AccountContact.js
new file mode 100644
index 0000000..a907e05
--- /dev/null
+++ b/src/views/components/AccountContact.js
@@ -0,0 +1,79 @@
+export default {
+ name: 'AccountContact',
+ data() {
+ return {
+ contacts: []
+ }
+ },
+ methods: {
+ async openModal() {
+ try {
+ this.dtClear()
+ await this.submitApi();
+ $('#modalContactList').modal('show');
+ this.dtRebuild()
+ showSuccessInfo("Contacts fetched")
+ } catch (err) {
+ showErrorInfo(err)
+ }
+ },
+ dtClear() {
+ $('#account_contacts_table').DataTable().destroy();
+ },
+ dtRebuild() {
+ $('#account_contacts_table').DataTable({
+ "pageLength": 10,
+ "reloadData": true,
+ }).draw();
+ },
+ async submitApi() {
+ try {
+ let response = await window.http.get(`/user/my/contacts`)
+ this.contacts = response.data.results.data;
+ } catch (error) {
+ if (error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error(error.message);
+ }
+ },
+ getPhoneNumber(jid) {
+ return jid.split('@')[0];
+ }
+ },
+ template: `
+
+
+
Contacts
+
+
+ Display all your contacts
+
+
+
+
+
+
+ `
+}
diff --git a/src/views/components/AccountPrivacy.js b/src/views/components/AccountPrivacy.js
index 58d35ab..fb47368 100644
--- a/src/views/components/AccountPrivacy.js
+++ b/src/views/components/AccountPrivacy.js
@@ -28,7 +28,7 @@ export default {
},
},
template: `
-
+
Account
diff --git a/src/views/components/AccountUserInfo.js b/src/views/components/AccountUserInfo.js
index 56b5494..baa7a3e 100644
--- a/src/views/components/AccountUserInfo.js
+++ b/src/views/components/AccountUserInfo.js
@@ -72,7 +72,7 @@ export default {
}
},
template: `
-
+
Account
diff --git a/src/views/index.html b/src/views/index.html
index 67590cf..ff1c486 100644
--- a/src/views/index.html
+++ b/src/views/index.html
@@ -157,6 +157,7 @@
+
@@ -218,6 +219,7 @@
import AccountUserInfo from "./components/AccountUserInfo.js";
import AccountPrivacy from "./components/AccountPrivacy.js";
import NewsletterList from "./components/NewsletterList.js";
+ import AccountContact from "./components/AccountContact.js";
const showErrorInfo = (message) => {
$('body').toast({
@@ -256,7 +258,7 @@
MessageDelete, MessageUpdate, MessageReact, MessageRevoke,
GroupList, GroupCreate, GroupJoinWithLink, GroupAddParticipants,
NewsletterList,
- AccountAvatar, AccountUserInfo, AccountPrivacy, AccountChangeAvatar
+ AccountAvatar, AccountUserInfo, AccountPrivacy, AccountChangeAvatar, AccountContact
},
delimiters: ['[[', ']]'],
data() {