Browse Source

Merge branch 'main' into bug/large_groups

pull/254/head
almogbaku 1 year ago
parent
commit
8c019a90ef
No known key found for this signature in database GPG Key ID: 66C92B1C5B475512
  1. 4
      .github/workflows/release-linux.yml
  2. 2
      .github/workflows/release-mac.yml
  3. 2
      .github/workflows/release-windows.yml
  4. 2
      docker-compose.yml
  5. 8
      docker/golang.Dockerfile
  6. 47
      docs/openapi.yaml
  7. 5
      readme.md
  8. 22
      src/cmd/root.go
  9. 2
      src/config/settings.go
  10. 12
      src/domains/user/account.go
  11. 1
      src/domains/user/user.go
  12. 8
      src/go.mod
  13. 8
      src/go.sum
  14. 5
      src/internal/rest/message.go
  15. 13
      src/internal/rest/user.go
  16. 100
      src/internal/websocket/websocket.go
  17. 26
      src/pkg/utils/general.go
  18. 3
      src/pkg/utils/general_test.go
  19. 179
      src/pkg/whatsapp/init.go
  20. 12
      src/pkg/whatsapp/utils.go
  21. 2
      src/pkg/whatsapp/webhook.go
  22. 7
      src/services/send.go
  23. 18
      src/services/user.go
  24. 2
      src/views/components/AccountAvatar.js
  25. 2
      src/views/components/AccountChangeAvatar.js
  26. 79
      src/views/components/AccountContact.js
  27. 2
      src/views/components/AccountPrivacy.js
  28. 2
      src/views/components/AccountUserInfo.js
  29. 4
      src/views/index.html

4
.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

2
.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

2
.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

2
docker-compose.yml

@ -8,7 +8,7 @@ services:
build:
context: .
dockerfile: ./docker/golang.Dockerfile
restart: 'always'
restart: "on-failure"
ports:
- "3000:3000"
env_file:

8
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

47
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:

5
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`.<br>
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 |

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

2
src/config/settings.go

@ -5,7 +5,7 @@ import (
)
var (
AppVersion = "v5.2.0"
AppVersion = "v5.3.0"
AppPort = "3000"
AppDebug = false
AppOs = "AldinoKemal"

12
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"`
}

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

8
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

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

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

13
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,
})
}

100
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 RunHub() {
for {
select {
case connection := <-Register:
Clients[connection] = client{}
func handleRegister(conn *websocket.Conn) {
Clients[conn] = client{}
log.Println("connection registered")
}
case message := <-Broadcast:
log.Println("message received:", message)
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("write error:", err)
log.Println("marshal error:", err)
return
}
// Send the message to all clients
for connection := range Clients {
if err := connection.WriteMessage(websocket.TextMessage, marshalMessage); err != nil {
for conn := range Clients {
if err := conn.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
closeConnection(conn)
}
err = connection.Close()
if err != nil {
log.Println("close error:", err)
return
}
delete(Clients, connection)
}
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)
}
case connection := <-Unregister:
// Remove the client from the hub
delete(Clients, connection)
func RunHub() {
for {
select {
case conn := <-Register:
handleRegister(conn)
log.Println("connection unregistered")
case conn := <-Unregister:
handleUnregister(conn)
case message := <-Broadcast:
log.Println("message received:", message)
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)
}
}
}))

26
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 @

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

179
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,45 +101,107 @@ 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:
handleAppStateSyncComplete(evt)
case *events.PairSuccess:
handlePairSuccess(evt)
case *events.LoggedOut:
handleLoggedOut()
case *events.Connected, *events.PushNameSetting:
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())
}
func handleAppStateSyncComplete(evt *events.AppStateSyncComplete) {
if len(cli.Store.PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
err := cli.SendPresence(types.PresenceAvailable)
if err != nil {
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.PairSuccess:
}
func handlePairSuccess(evt *events.PairSuccess) {
websocket.Broadcast <- websocket.BroadcastMessage{
Code: "LOGIN_SUCCESS",
Message: fmt.Sprintf("Successfully pair with %s", evt.ID.String()),
}
case *events.LoggedOut:
}
func handleLoggedOut() {
websocket.Broadcast <- websocket.BroadcastMessage{
Code: "LIST_DEVICES",
Result: nil,
}
case *events.Connected, *events.PushNameSetting:
}
func handleConnectionEvents() {
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 {
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:
}
func handleStreamReplaced() {
os.Exit(0)
case *events.Message:
}
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),
@ -159,11 +215,10 @@ func handler(rawEvt interface{}) {
if evt.IsViewOnce {
metaParts = append(metaParts, "view once")
}
return metaParts
}
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 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)
@ -171,13 +226,22 @@ func handler(rawEvt interface{}) {
log.Infof("Image downloaded to %s", path)
}
}
}
func handleAutoReply(evt *events.Message) {
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)})
!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()) {
@ -187,13 +251,17 @@ func handler(rawEvt interface{}) {
}
}(evt)
}
case *events.Receipt:
}
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)
}
case *events.Presence:
}
func handlePresence(evt *events.Presence) {
if evt.Unavailable {
if evt.LastSeen.IsZero() {
log.Infof("%s is now offline", evt.From)
@ -203,24 +271,35 @@ func handler(rawEvt interface{}) {
} else {
log.Infof("%s is now online", evt.From)
}
case *events.HistorySync:
}
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())
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("", " ")
err = enc.Encode(evt.Data)
if err != nil {
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)
_ = file.Close()
case *events.AppState:
log.Debugf("App state event: %+v / %+v", evt.Index, evt.SyncActionValue)
}
func handleAppState(evt *events.AppState) {
log.Debugf("App state event: %+v / %+v", evt.Index, evt.SyncActionValue)
}

12
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")

2
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 != "" {

7
src/services/send.go

@ -430,7 +430,10 @@ 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)),
@ -438,6 +441,8 @@ func (service serviceSend) SendLink(ctx context.Context, request domainSend.Link
MatchedText: proto.String(request.Link),
Description: proto.String(getMetaDataFromURL.Description),
JPEGThumbnail: getMetaDataFromURL.ImageThumb,
ThumbnailHeight: getMetaDataFromURL.Height,
ThumbnailWidth: getMetaDataFromURL.Width,
}}
content := "🔗 " + request.Link

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

2
src/views/components/AccountAvatar.js

@ -65,7 +65,7 @@ export default {
}
},
template: `
<div class="green card" @click="openModal" style="cursor: pointer;">
<div class="olive card" @click="openModal" style="cursor: pointer;">
<div class="content">
<a class="ui olive right ribbon label">Account</a>
<div class="header">Avatar</div>

2
src/views/components/AccountChangeAvatar.js

@ -63,7 +63,7 @@ export default {
}
},
template: `
<div class="blue card" @click="openModal()" style="cursor:pointer;">
<div class="olive card" @click="openModal()" style="cursor:pointer;">
<div class="content">
<a class="ui olive right ribbon label">Account</a>
<div class="header">Change Avatar</div>

79
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: `
<div class="olive card" @click="openModal" style="cursor: pointer">
<div class="content">
<a class="ui olive right ribbon label">Contacts</a>
<div class="header">My Contacts</div>
<div class="description">
Display all your contacts
</div>
</div>
</div>
<!-- Modal Contact List -->
<div class="ui large modal" id="modalContactList">
<i class="close icon"></i>
<div class="header">
My Contacts
</div>
<div class="content">
<table class="ui celled table" id="account_contacts_table">
<thead>
<tr>
<th>Phone Number</th>
<th>Name</th>
</tr>
</thead>
<tbody v-if="contacts != null">
<tr v-for="contact in contacts">
<td>{{ getPhoneNumber(contact.jid) }}</td>
<td>{{ contact.name }}</td>
</tr>
</tbody>
</table>
</div>
</div>
`
}

2
src/views/components/AccountPrivacy.js

@ -28,7 +28,7 @@ export default {
},
},
template: `
<div class="green card" @click="openModal" style="cursor: pointer">
<div class="olive card" @click="openModal" style="cursor: pointer">
<div class="content">
<a class="ui olive right ribbon label">Account</a>
<div class="header">My Privacy Setting</div>

2
src/views/components/AccountUserInfo.js

@ -72,7 +72,7 @@ export default {
}
},
template: `
<div class="green card" @click="openModal" style="cursor: pointer;">
<div class="olive card" @click="openModal" style="cursor: pointer;">
<div class="content">
<a class="ui olive right ribbon label">Account</a>
<div class="header">User Info</div>

4
src/views/index.html

@ -157,6 +157,7 @@
<account-change-avatar></account-change-avatar>
<account-user-info></account-user-info>
<account-privacy></account-privacy>
<account-contact></account-contact>
</div>
</div>
@ -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() {

Loading…
Cancel
Save