Browse Source

feat: wa status, enhance reply, update ui, multiple webhook

commit acb35b63b7
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Fri Jan 31 07:10:27 2025 +0700

    feat: change multiple basic auth

commit 993b5188c5
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Fri Jan 31 07:06:57 2025 +0700

    feat: support multiple webhook

commit 1308039a16
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Tue Jan 21 19:41:15 2025 +0700

    fix: login with code

commit 1a55ccb3eb
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Tue Jan 21 15:12:45 2025 +0700

    feat: add star message

commit 6a4ef88115
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Tue Jan 21 14:16:03 2025 +0700

    feat: add change avatar api

commit 52c738d2ce
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Tue Jan 21 12:07:13 2025 +0700

    feat: add send presence

commit 3e4cbf8cb2
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Tue Jan 21 10:52:05 2025 +0700

    fix: reply message

commit e08ae59992
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Tue Jan 21 08:32:21 2025 +0700

    fix: reply issue

commit fa80cf3502
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Tue Jan 21 05:34:55 2025 +0700

    feat: optimize UI validation

commit ac58b9579e
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Tue Jan 21 00:11:48 2025 +0700

    refactor: move isValidForm methods in SendImage, SendMessage, and SendVideo

    refactor(SendImage.js): move isValidForm method to computed section
    refactor(SendMessage.js): move isValidForm method to computed section
    refactor(SendVideo.js): remove errors object and resetErrors call
    fix(SendVideo.js): change isFormValid to isValidForm and ensure consistency

commit 1dc30bcd86
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Mon Jan 20 23:02:26 2025 +0700

    feat: optimize UI

commit e652eabb0a
Author: Aldino Kemal <aldinokemal2104@gmail.com>
Date:   Mon Jan 20 21:22:13 2025 +0700

    feat: update package
pull/239/head
Aldino Kemal 1 year ago
parent
commit
b7e0ada66e
  1. 77
      docs/openapi.yaml
  2. 125
      readme.md
  3. 16
      src/cmd/root.go
  4. 15
      src/config/settings.go
  5. 7
      src/domains/message/message.go
  6. 5
      src/domains/send/presence.go
  7. 1
      src/domains/send/send.go
  8. 9
      src/domains/user/account.go
  9. 1
      src/domains/user/user.go
  10. 13
      src/go.mod
  11. 21
      src/go.sum
  12. 41
      src/internal/rest/message.go
  13. 17
      src/internal/rest/send.go
  14. 19
      src/internal/rest/user.go
  15. 98
      src/pkg/utils/chat_storage.go
  16. 5
      src/pkg/whatsapp/init.go
  17. 111
      src/pkg/whatsapp/utils.go
  18. 10
      src/pkg/whatsapp/webhook.go
  19. 14
      src/services/app.go
  20. 27
      src/services/message.go
  21. 116
      src/services/send.go
  22. 60
      src/services/user.go
  23. 15
      src/validations/message_validation.go
  24. 13
      src/validations/send_validation.go
  25. 282
      src/views/assets/app.css
  26. 16
      src/views/components/AccountAvatar.js
  27. 113
      src/views/components/AccountChangeAvatar.js
  28. 1
      src/views/components/AccountPrivacy.js
  29. 16
      src/views/components/AccountUserInfo.js
  30. 1
      src/views/components/AppLogin.js
  31. 5
      src/views/components/AppLoginWithCode.js
  32. 1
      src/views/components/AppLogout.js
  33. 1
      src/views/components/AppReconnect.js
  34. 20
      src/views/components/GroupCreate.js
  35. 23
      src/views/components/GroupJoinWithLink.js
  36. 17
      src/views/components/GroupManageParticipants.js
  37. 21
      src/views/components/MessageDelete.js
  38. 18
      src/views/components/MessageReact.js
  39. 18
      src/views/components/MessageRevoke.js
  40. 18
      src/views/components/MessageUpdate.js
  41. 37
      src/views/components/SendAudio.js
  42. 25
      src/views/components/SendContact.js
  43. 37
      src/views/components/SendFile.js
  44. 60
      src/views/components/SendImage.js
  45. 26
      src/views/components/SendLocation.js
  46. 50
      src/views/components/SendMessage.js
  47. 25
      src/views/components/SendPoll.js
  48. 86
      src/views/components/SendPresence.js
  49. 64
      src/views/components/SendVideo.js
  50. 25
      src/views/components/generic/FormRecipient.js
  51. 102
      src/views/index.html

77
docs/openapi.yaml

@ -1,7 +1,7 @@
openapi: 3.0.0
openapi: "3.0.0"
info:
title: WhatsApp API MultiDevice
version: 4.4.0
version: 5.0.0
description: This API is used for sending whatsapp via API
servers:
- url: http://localhost:3000
@ -188,6 +188,40 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorInternalServer'
post:
operationId: userChangeAvatar
tags:
- user
summary: User Change Avatar
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
avatar:
type: string
format: binary
description: Avatar to send
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/GenericResponse'
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBadRequest'
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorInternalServer'
/user/my/privacy:
get:
operationId: userMyPrivacy
@ -656,6 +690,45 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorInternalServer'
/send/presence:
post:
operationId: sendPresence
tags:
- send
summary: Send presence status
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
presence:
type: string
description: The presence status to send
enum: [available, unavailable]
example: 'available'
required:
- presence
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SendResponse'
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBadRequest'
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorInternalServer'
/message/{message_id}/revoke:
post:
operationId: revokeMessage

125
readme.md

@ -1,60 +1,60 @@
## WhatsApp API Multi Device Version
# WhatsApp API Multi Device Version
![release version](https://img.shields.io/github/v/release/aldinokemal/go-whatsapp-web-multidevice)
<br>
![Build Image](https://github.com/aldinokemal/go-whatsapp-web-multidevice/actions/workflows/build-docker-image.yaml/badge.svg)
<br>
![release windows](https://github.com/aldinokemal/go-whatsapp-web-multidevice/actions/workflows/release-windows.yml/badge.svg)
![release linux](https://github.com/aldinokemal/go-whatsapp-web-multidevice/actions/workflows/release-linux.yml/badge.svg)
![release macos](https://github.com/aldinokemal/go-whatsapp-web-multidevice/actions/workflows/release-mac.yml/badge.svg)
### Support `ARM` Architecture
## Support `ARM` Architecture
Now that we support ARM64 for Linux:
- [Release](https://github.com/aldinokemal/go-whatsapp-web-multidevice/releases/latest) for ARM64
- [Docker Image](https://hub.docker.com/r/aldinokemal2104/go-whatsapp-web-multidevice/tags) for ARM64.
### Feature
## Feature
- Send WhatsApp message via http API, [docs/openapi.yml](./docs/openapi.yaml) for more details
- Compress image before send
- Compress video before send
- Change OS name become your app (it's the device name when connect via mobile)
- `--os=Chrome` or `--os=MyApplication`
- `--os=Chrome` or `--os=MyApplication`
- Basic Auth (able to add multi credentials)
- `--basic-auth=kemal:secret,toni:password,userName:secretPassword`, or you can simplify
- `-b=kemal:secret,toni:password,userName:secretPassword`
- `--basic-auth=kemal:secret,toni:password,userName:secretPassword`, or you can simplify
- `-b=kemal:secret,toni:password,userName:secretPassword`
- Customizable port and debug mode
- `--port 8000`
- `--debug true`
- `--port 8000`
- `--debug true`
- Auto reply message
- `--autoreply="Don't reply this message"`
- `--autoreply="Don't reply this message"`
- Webhook for received message
- `--webhook="http://yourwebhook.site/handler"`, or you can simplify
- `-w="http://yourwebhook.site/handler"`
- `--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>
You may modify this by using the option below:
- `--webhook-secret="secret"`
- `--webhook-secret="secret"`
- For more command `./main --help`
### Required (without docker)
## Required (without docker)
- Mac OS:
- `brew install ffmpeg`
- `export CGO_CFLAGS_ALLOW="-Xpreprocessor"`
- `brew install ffmpeg`
- `export CGO_CFLAGS_ALLOW="-Xpreprocessor"`
- Linux:
- `sudo apt update`
- `sudo apt install ffmpeg`
- `sudo apt update`
- `sudo apt install ffmpeg`
- Windows (not recomended, prefer using [WSL](https://docs.microsoft.com/en-us/windows/wsl/install)):
- install ffmpeg, download [here](https://www.ffmpeg.org/download.html#build-windows)
- add to ffmpeg to [environment variable](https://www.google.com/search?q=windows+add+to+environment+path)
- install ffmpeg, download [here](https://www.ffmpeg.org/download.html#build-windows)
- add to ffmpeg to [environment variable](https://www.google.com/search?q=windows+add+to+environment+path)
### How to use
## How to use
#### Basic
### Basic
1. Clone this repo: `git clone https://github.com/aldinokemal/go-whatsapp-web-multidevice`
2. Open the folder that was cloned via cmd/terminal.
@ -62,14 +62,14 @@ Now that we support ARM64 for Linux:
4. run `go run main.go`
5. Open `http://localhost:3000`
#### Docker (you don't need to install in required)
### Docker (you don't need to install in required)
1. Clone this repo: `git clone https://github.com/aldinokemal/go-whatsapp-web-multidevice`
2. Open the folder that was cloned via cmd/terminal.
3. run `docker-compose up -d --build`
4. open `http://localhost:3000`
#### Build your own binary
### Build your own binary
1. Clone this repo `git clone https://github.com/aldinokemal/go-whatsapp-web-multidevice`
2. Open the folder that was cloned via cmd/terminal.
@ -86,7 +86,7 @@ Now that we support ARM64 for Linux:
### Production Mode (docker)
```
```bash
docker run --detach --publish=3000:3000 --name=whatsapp --restart=always --volume=$(docker volume create --name=whatsapp):/app/storages aldinokemal2104/go-whatsapp-web-multidevice --autoreply="Dont't reply this message please"
```
@ -96,34 +96,36 @@ docker run --detach --publish=3000:3000 --name=whatsapp --restart=always --volum
You can fork or edit this source code !
### Current API
## Current API
- [Api Specification Document](https://bump.sh/aldinokemal/doc/go-whatsapp-web-multidevice)
- You can check [docs/openapi.yml](./docs/openapi.yaml) for detail API or paste
to [SwaggerEditor](https://editor.swagger.io).
- Furthermore you can generate HTTP Client from this API using [openapi-generator](https://openapi-generator.tech/#try)
| Feature | Menu | Method | URL |
| Feature | Menu | Method | URL |
|---------|------------------------------|--------|-------------------------------|
| ✅ | Login with Scan QR | GET | /app/login |
| ✅ | Login With Pair Code | GET | /app/login-with-code |
| ✅ | Logout | GET | /app/logout |
| ✅ | Reconnect | GET | /app/reconnect |
| ✅ | Devices | GET | /app/devices |
| ✅ | Reconnect | GET | /app/reconnect |
| ✅ | Devices | GET | /app/devices |
| ✅ | User Info | GET | /user/info |
| ✅ | User Avatar | GET | /user/avatar |
| ✅ | User Change Avatar | POST | /user/avatar |
| ✅ | User My Groups | GET | /user/my/groups |
| ✅ | User My Newsletter | GET | /user/my/newsletters |
| ✅ | User My Privacy Setting | GET | /user/my/privacy |
| ✅ | Send Message | POST | /send/message |
| ✅ | Send Image | POST | /send/image |
| ✅ | Send Audio | POST | /send/audio |
| ✅ | Send File | POST | /send/file |
| ✅ | Send Video | POST | /send/video |
| ✅ | Send Image | POST | /send/image |
| ✅ | Send Audio | POST | /send/audio |
| ✅ | Send File | POST | /send/file |
| ✅ | Send Video | POST | /send/video |
| ✅ | Send Contact | POST | /send/contact |
| ✅ | Send Link | POST | /send/link |
| ✅ | Send Location | POST | /send/location |
| ✅ | Send Poll / Vote | POST | /send/poll |
| ✅ | Send Presence | POST | /send/presence |
| ✅ | Revoke Message | POST | /message/:message_id/revoke |
| ✅ | React Message | POST | /message/:message_id/reaction |
| ✅ | Delete Message | POST | /message/:message_id/delete |
@ -139,38 +141,41 @@ You can fork or edit this source code !
| ✅ | Demote Participant in Group | POST | /group/participants/demote |
| ✅ | Unfollow Newsletter | POST | /newsletter/unfollow |
```
```txt
✅ = Available
❌ = Not Available Yet
```
### User Interface
| Description | Image |
|--------------------|------------------------------------------------------------------------------------------|
| Homepage | ![Homepage](https://i.ibb.co.com/Sy0dHZp/homepage-v4-20.png) |
| Login | ![Login](https://i.ibb.co.com/jkcB15R/login.png?v=1) |
| Login With Code | ![Login With Code](https://i.ibb.co.com/rdJGvGw/paircode.png) |
| Send Message | ![Send Message](https://i.ibb.co.com/rc3NXMX/send-message.png?v1) |
| Send Image | ![Send Image](https://i.ibb.co.com/BcFL3SD/send-image.png?v1) |
| Send File | ![Send File](https://i.ibb.co.com/f4yxjpp/send-file.png) |
| Send Video | ![Send Video](https://i.ibb.co.com/PrD3P51/send-video.png) |
| Send Contact | ![Send Contact](https://i.ibb.co.com/4810H7N/send-contact.png) |
| Send Location | ![Send Location](https://i.ibb.co.com/TWsy09G/send-location.png) |
| Send Audio | ![Send Audio](https://i.ibb.co.com/p1wL4wh/Send-Audio.png) |
| Send Poll | ![Send Poll](https://i.ibb.co.com/mq2fGHz/send-poll.png) |
| Revoke Message | ![Revoke Message](https://i.ibb.co.com/yswhvQY/revoke.png?v1) |
| Delete Message | ![Delete Message](https://i.ibb.co.com/F70SZ84/image.png) |
| Reaction Message | ![Reaction Message](https://i.ibb.co.com/BfHgSHG/react-message.png) |
| Edit Message | ![Edit Message](https://i.ibb.co.com/kXfpqJw/update-message.png) |
| User Info | ![User Info](https://i.ibb.co.com/3zjX6Cz/user-info.png?v=1) |
| User Avatar | ![User Avatar](https://i.ibb.co.com/ZmJZ4ZW/search-avatar.png?v=1) |
| My Privacy | ![My Privacy](https://i.ibb.co.com/Cw1sMQz/my-privacy.png) |
| My Group | ![My Group](https://i.ibb.co.com/WB268Xy/list-group.png) |
| Auto Reply | ![Auto Reply](https://i.ibb.co.com/D4rTytX/IMG-20220517-162500.jpg) |
| Basic Auth Prompt | ![Basic Auth Prompt](https://i.ibb.co.com/PDjQ92W/Screenshot-2022-11-06-at-14-06-29.png) |
| Manage Participant | ![Manage Participant](https://i.ibb.co.com/ynrN7cr/manage-participant.png) |
| My Newsletter | ![List Newsletter](https://i.ibb.co.com/WDg50jJ/image.png) |
| Description | Image |
|----------------------|------------------------------------------------------------------------------------------|
| Homepage | ![Homepage](https://i.ibb.co/251sHyF/Homepage.png) |
| Login | ![Login](https://i.ibb.co.com/xJyCWv8/login.png) |
| Login With Code | ![Login With Code](https://i.ibb.co.com/YDjyXby/login-With-Code.png) |
| Send Message | ![Send Message](https://i.ibb.co.com/7Y0wJ5R/send-Message.png) |
| Send Image | ![Send Image](https://i.ibb.co.com/NtyLLdS/send-Image.png) |
| Send File | ![Send File](https://i.ibb.co.com/D94yvnX/sendFile.png) |
| Send Video | ![Send Video](https://i.ibb.co.com/r0LdRFH/send-Video.png) |
| Send Contact | ![Send Contact](https://i.ibb.co.com/NsFfQBv/send-Contact.png) |
| Send Location | ![Send Location](https://i.ibb.co.com/vDGmFvk/send-Location.png) |
| Send Audio | ![Send Audio](https://i.ibb.co.com/XJdQLP8/send-Audio.png) |
| Send Poll | ![Send Poll](https://i.ibb.co.com/4TswfT3/sendPoll.png) |
| Send Presence | ![Send Presence](https://i.ibb.co.com/NSTC3QX/send-Presence.png) |
| Revoke Message | ![Revoke Message](https://i.ibb.co.com/r4nDc57/revoke-Message.png) |
| Delete Message | ![Delete Message](https://i.ibb.co.com/dtrTJ1M/delete-Message.png) |
| Reaction Message | ![Reaction Message](https://i.ibb.co.com/fNqJXF0/react-Message.png) |
| Edit Message | ![Edit Message](https://i.ibb.co.com/Vx5cQMg/update-Message.png) |
| User Info | ![User Info](https://i.ibb.co.com/qd7J7Nd/Search-User-Info.png) |
| User Avatar | ![User Avatar](https://i.ibb.co.com/zbSzpP3/Search-Avatar.png) |
| My Privacy | ![My Privacy](https://i.ibb.co.com/HCLxGHr/My-Privacy.png) |
| My Group | ![My Group](https://i.ibb.co.com/p19dFQ5/list-Group.png) |
| Create Group | ![My Group](https://i.ibb.co.com/YLT5Hyh/create-Group.png) |
| Join Group with LInk | ![My Group](https://i.ibb.co.com/x5p95J7/join-Group-With-Link.png) |
| Auto Reply | ![Auto Reply](https://i.ibb.co.com/D4rTytX/IMG-20220517-162500.jpg) |
| Basic Auth Prompt | ![Basic Auth Prompt](https://i.ibb.co.com/PDjQ92W/Screenshot-2022-11-06-at-14-06-29.png) |
| Manage Participant | ![Manage Participant](https://i.ibb.co.com/x7yn9nY/Manage-Participant.png) |
| My Newsletter | ![List Newsletter](https://i.ibb.co.com/BLvyS9j/My-Newsletter.png) |
### Mac OS NOTE

16
src/cmd/root.go

@ -46,9 +46,9 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&config.AppPort, "port", "p", config.AppPort, "change port number with --port <number> | example: --port=8080")
rootCmd.PersistentFlags().BoolVarP(&config.AppDebug, "debug", "d", config.AppDebug, "hide or displaying log with --debug <true/false> | example: --debug=true")
rootCmd.PersistentFlags().StringVarP(&config.AppOs, "os", "", config.AppOs, `os name --os <string> | example: --os="Chrome"`)
rootCmd.PersistentFlags().StringVarP(&config.AppBasicAuthCredential, "basic-auth", "b", config.AppBasicAuthCredential, "basic auth credential | -b=yourUsername:yourPassword")
rootCmd.PersistentFlags().StringSliceVarP(&config.AppBasicAuthCredential, "basic-auth", "b", config.AppBasicAuthCredential, "basic auth credential | -b=yourUsername:yourPassword")
rootCmd.PersistentFlags().StringVarP(&config.WhatsappAutoReplyMessage, "autoreply", "", config.WhatsappAutoReplyMessage, `auto reply when received message --autoreply <string> | example: --autoreply="Don't reply this message"`)
rootCmd.PersistentFlags().StringVarP(&config.WhatsappWebhook, "webhook", "w", config.WhatsappWebhook, `forward event to webhook --webhook <string> | example: --webhook="https://yourcallback.com/callback"`)
rootCmd.PersistentFlags().StringSliceVarP(&config.WhatsappWebhook, "webhook", "w", config.WhatsappWebhook, `forward event to webhook --webhook <string> | example: --webhook="https://yourcallback.com/callback"`)
rootCmd.PersistentFlags().StringVarP(&config.WhatsappWebhookSecret, "webhook-secret", "", config.WhatsappWebhookSecret, `secure webhook request --webhook-secret <string> | example: --webhook-secret="super-secret-key"`)
rootCmd.PersistentFlags().BoolVarP(&config.WhatsappAccountValidation, "account-validation", "", config.WhatsappAccountValidation, `enable or disable account validation --account-validation <true/false> | example: --account-validation=true`)
rootCmd.PersistentFlags().StringVarP(&config.DBURI, "db-uri", "", config.DBURI, `the database uri to store the connection data database uri (by default, we'll use sqlite3 under storages/whatsapp.db). database uri --db-uri <string> | example: --db-uri="file:storages/whatsapp.db?_foreign_keys=off or postgres://user:password@localhost:5432/whatsapp"`)
@ -74,12 +74,19 @@ func runRest(_ *cobra.Command, _ []string) {
Views: engine,
BodyLimit: int(config.WhatsappSettingMaxVideoSize),
})
app.Static("/statics", "./statics")
app.Use("/components", filesystem.New(filesystem.Config{
Root: http.FS(EmbedViews),
PathPrefix: "views/components",
Browse: true,
}))
app.Use("/assets", filesystem.New(filesystem.Config{
Root: http.FS(EmbedViews),
PathPrefix: "views/assets",
Browse: true,
}))
app.Use(middleware.Recovery())
app.Use(middleware.BasicAuth())
if config.AppDebug {
@ -90,10 +97,9 @@ func runRest(_ *cobra.Command, _ []string) {
AllowHeaders: "Origin, Content-Type, Accept",
}))
if config.AppBasicAuthCredential != "" {
if len(config.AppBasicAuthCredential) > 0 {
account := make(map[string]string)
multipleBA := strings.Split(config.AppBasicAuthCredential, ",")
for _, basicAuth := range multipleBA {
for _, basicAuth := range config.AppBasicAuthCredential {
ba := strings.Split(basicAuth, ":")
if len(ba) != 2 {
log.Fatalln("Basic auth is not valid, please this following format <user>:<secret>")

15
src/config/settings.go

@ -5,22 +5,23 @@ import (
)
var (
AppVersion = "v4.22.1"
AppVersion = "v5.0.0"
AppPort = "3000"
AppDebug = false
AppOs = "AldinoKemal"
AppPlatform = waCompanionReg.DeviceProps_PlatformType(1)
AppBasicAuthCredential string
AppBasicAuthCredential []string
PathQrCode = "statics/qrcode"
PathSendItems = "statics/senditems"
PathMedia = "statics/media"
PathStorages = "storages"
PathQrCode = "statics/qrcode"
PathSendItems = "statics/senditems"
PathMedia = "statics/media"
PathStorages = "storages"
PathChatStorage = "storages/chat.txt"
DBURI = "file:storages/whatsapp.db?_foreign_keys=off"
WhatsappAutoReplyMessage string
WhatsappWebhook string
WhatsappWebhook []string
WhatsappWebhookSecret = "secret"
WhatsappLogLevel = "ERROR"
WhatsappSettingMaxFileSize int64 = 50000000 // 50MB

7
src/domains/message/message.go

@ -8,6 +8,7 @@ type IMessageService interface {
RevokeMessage(ctx context.Context, request RevokeRequest) (response GenericResponse, err error)
UpdateMessage(ctx context.Context, request UpdateMessageRequest) (response GenericResponse, err error)
DeleteMessage(ctx context.Context, request DeleteRequest) (err error)
StarMessage(ctx context.Context, request StarRequest) (err error)
}
type GenericResponse struct {
@ -41,3 +42,9 @@ type MarkAsReadRequest struct {
MessageID string `json:"message_id" uri:"message_id"`
Phone string `json:"phone" form:"phone"`
}
type StarRequest struct {
MessageID string `json:"message_id" uri:"message_id"`
Phone string `json:"phone" form:"phone"`
IsStarred bool `json:"is_starred"`
}

5
src/domains/send/presence.go

@ -0,0 +1,5 @@
package send
type PresenceRequest struct {
Type string `json:"type" form:"type"`
}

1
src/domains/send/send.go

@ -14,6 +14,7 @@ type ISendService interface {
SendLocation(ctx context.Context, request LocationRequest) (response GenericResponse, err error)
SendAudio(ctx context.Context, request AudioRequest) (response GenericResponse, err error)
SendPoll(ctx context.Context, request PollRequest) (response GenericResponse, err error)
SendPresence(ctx context.Context, request PresenceRequest) (response GenericResponse, err error)
}
type GenericResponse struct {

9
src/domains/user/account.go

@ -1,6 +1,9 @@
package user
import "go.mau.fi/whatsmeow/types"
import (
"go.mau.fi/whatsmeow/types"
"mime/multipart"
)
type InfoRequest struct {
Phone string `json:"phone" query:"phone"`
@ -52,3 +55,7 @@ type MyListGroupsResponse struct {
type MyListNewsletterResponse struct {
Data []types.NewsletterMetadata `json:"data"`
}
type ChangeAvatarRequest struct {
Avatar *multipart.FileHeader `json:"avatar" form:"avatar"`
}

1
src/domains/user/user.go

@ -7,6 +7,7 @@ import (
type IUserService interface {
Info(ctx context.Context, request InfoRequest) (response InfoResponse, err error)
Avatar(ctx context.Context, request AvatarRequest) (response AvatarResponse, err error)
ChangeAvatar(ctx context.Context, request ChangeAvatarRequest) (err error)
MyListGroups(ctx context.Context) (response MyListGroupsResponse, err error)
MyListNewsletter(ctx context.Context) (response MyListNewsletterResponse, err error)
MyPrivacySetting(ctx context.Context) (response MyPrivacySettingResponse, err error)

13
src/go.mod

@ -10,7 +10,7 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/gofiber/fiber/v2 v2.52.6
github.com/gofiber/template/html/v2 v2.1.2
github.com/gofiber/template/html/v2 v2.1.3
github.com/gofiber/websocket/v2 v2.2.1
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
@ -22,7 +22,7 @@ require (
github.com/valyala/fasthttp v1.58.0
go.mau.fi/libsignal v0.1.1
go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19
google.golang.org/protobuf v1.36.1
google.golang.org/protobuf v1.36.3
)
require (
@ -38,7 +38,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@ -48,12 +48,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.3 // indirect
golang.org/x/crypto v0.31.0 // indirect
go.mau.fi/util v0.8.4 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.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
)

21
src/go.sum

@ -27,8 +27,8 @@ github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27X
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/html/v2 v2.1.2 h1:wkK/mYJ3nIhongTkG3t0QgV4ADdgOYJYVSAF2AHnh8Y=
github.com/gofiber/template/html/v2 v2.1.2/go.mod h1:E98Z/FzvpaSib06aWEgYk6GXNf3ctoyaJH8yW5ay5ak=
github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
@ -50,8 +50,9 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -96,8 +97,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/libsignal v0.1.1 h1:m/0PGBh4QKP/I1MQ44ti4C0fMbLMuHb95cmDw01FIpI=
go.mau.fi/libsignal v0.1.1/go.mod h1:QLs89F/OA3ThdSL2Wz2p+o+fi8uuQUz0e1BRa6ExdBw=
go.mau.fi/util v0.8.3 h1:sulhXtfquMrQjsOP67x9CzWVBYUwhYeoo8hNQIpCWZ4=
go.mau.fi/util v0.8.3/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY=
go.mau.fi/util v0.8.4 h1:mVKlJcXWfVo8ZW3f4vqtjGpqtZqJvX4ETekxawt2vnQ=
go.mau.fi/util v0.8.4/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19 h1:uVS+Zct5fF8rSXV9lfs87zoXdge0JXTzVGNkjmZ61UU=
go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19/go.mod h1:TLzm2XkwgufONEmiVAsFny+9uBqyEZnUoPrQAfMyuSU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -105,8 +106,9 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
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 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
@ -123,8 +125,9 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
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/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
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=
@ -173,8 +176,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/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=

41
src/internal/rest/message.go

@ -19,6 +19,8 @@ func InitRestMessage(app *fiber.App, service domainMessage.IMessageService) Mess
app.Post("/message/:message_id/delete", rest.DeleteMessage)
app.Post("/message/:message_id/update", rest.UpdateMessage)
app.Post("/message/:message_id/read", rest.MarkAsRead)
app.Post("/message/:message_id/star", rest.StarMessage)
app.Post("/message/:message_id/unstar", rest.UnstarMessage)
return rest
}
@ -116,3 +118,42 @@ func (controller *Message) MarkAsRead(c *fiber.Ctx) error {
Results: response,
})
}
func (controller *Message) StarMessage(c *fiber.Ctx) error {
var request domainMessage.StarRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
request.MessageID = c.Params("message_id")
whatsapp.SanitizePhone(&request.Phone)
request.IsStarred = true
err = controller.Service.StarMessage(c.UserContext(), request)
utils.PanicIfNeeded(err)
return c.JSON(utils.ResponseData{
Status: 200,
Code: "SUCCESS",
Message: "Starred message successfully",
Results: nil,
})
}
func (controller *Message) UnstarMessage(c *fiber.Ctx) error {
var request domainMessage.StarRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
request.MessageID = c.Params("message_id")
whatsapp.SanitizePhone(&request.Phone)
request.IsStarred = false
err = controller.Service.StarMessage(c.UserContext(), request)
utils.PanicIfNeeded(err)
return c.JSON(utils.ResponseData{
Status: 200,
Code: "SUCCESS",
Message: "Unstarred message successfully",
Results: nil,
})
}

17
src/internal/rest/send.go

@ -22,6 +22,7 @@ func InitRestSend(app *fiber.App, service domainSend.ISendService) Send {
app.Post("/send/location", rest.SendLocation)
app.Post("/send/audio", rest.SendAudio)
app.Post("/send/poll", rest.SendPoll)
app.Post("/send/presence", rest.SendPresence)
return rest
}
@ -204,3 +205,19 @@ func (controller *Send) SendPoll(c *fiber.Ctx) error {
Results: response,
})
}
func (controller *Send) SendPresence(c *fiber.Ctx) error {
var request domainSend.PresenceRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
response, err := controller.Service.SendPresence(c.UserContext(), request)
utils.PanicIfNeeded(err)
return c.JSON(utils.ResponseData{
Status: 200,
Code: "SUCCESS",
Message: response.Status,
Results: response,
})
}

19
src/internal/rest/user.go

@ -15,6 +15,7 @@ func InitRestUser(app *fiber.App, service domainUser.IUserService) User {
rest := User{Service: service}
app.Get("/user/info", rest.UserInfo)
app.Get("/user/avatar", rest.UserAvatar)
app.Post("/user/avatar", rest.UserChangeAvatar)
app.Get("/user/my/privacy", rest.UserMyPrivacySetting)
app.Get("/user/my/groups", rest.UserMyListGroups)
app.Get("/user/my/newsletters", rest.UserMyListNewsletter)
@ -58,6 +59,24 @@ func (controller *User) UserAvatar(c *fiber.Ctx) error {
})
}
func (controller *User) UserChangeAvatar(c *fiber.Ctx) error {
var request domainUser.ChangeAvatarRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
request.Avatar, err = c.FormFile("avatar")
utils.PanicIfNeeded(err)
err = controller.Service.ChangeAvatar(c.UserContext(), request)
utils.PanicIfNeeded(err)
return c.JSON(utils.ResponseData{
Status: 200,
Code: "SUCCESS",
Message: "Success change avatar",
})
}
func (controller *User) UserMyPrivacySetting(c *fiber.Ctx) error {
response, err := controller.Service.MyPrivacySetting(c.UserContext())
utils.PanicIfNeeded(err)

98
src/pkg/utils/chat_storage.go

@ -0,0 +1,98 @@
package utils
import (
"fmt"
"os"
"strings"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
"github.com/gofiber/fiber/v2/log"
)
type RecordedMessage struct {
MessageID string `json:"message_id,omitempty"`
JID string `json:"jid,omitempty"`
MessageContent string `json:"message_content,omitempty"`
}
func FindRecordFromStorage(messageID string) (RecordedMessage, error) {
data, err := os.ReadFile(config.PathChatStorage)
if err != nil {
return RecordedMessage{}, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Split(line, ",")
if len(parts) == 3 && parts[0] == messageID {
return RecordedMessage{
MessageID: parts[0],
JID: parts[1],
MessageContent: parts[2],
}, nil
}
}
return RecordedMessage{}, fmt.Errorf("message ID %s not found in storage", messageID)
}
func RecordMessage(messageID string, senderJID string, messageContent string) {
message := RecordedMessage{
MessageID: messageID,
JID: senderJID,
MessageContent: messageContent,
}
// Read existing messages
var messages []RecordedMessage
if data, err := os.ReadFile(config.PathChatStorage); err == nil {
// Split file by newlines and parse each line
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Split(line, ",")
msg := RecordedMessage{
MessageID: parts[0],
JID: parts[1],
MessageContent: parts[2],
}
messages = append(messages, msg)
}
}
// Check for duplicates
for _, msg := range messages {
if msg.MessageID == message.MessageID {
return // Skip if duplicate found
}
}
// Write new message at the top
f, err := os.OpenFile(config.PathChatStorage, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
log.Errorf("Failed to open received-chat.txt: %v", err)
return
}
defer f.Close()
// Write new message first
csvLine := fmt.Sprintf("%s,%s,%s\n", message.MessageID, message.JID, message.MessageContent)
if _, err := f.WriteString(csvLine); err != nil {
log.Errorf("Failed to write to received-chat.txt: %v", err)
return
}
// Write existing messages after
for _, msg := range messages {
csvLine := fmt.Sprintf("%s,%s,%s\n", msg.MessageID, msg.JID, msg.MessageContent)
if _, err := f.WriteString(csvLine); err != nil {
log.Errorf("Failed to write to received-chat.txt: %v", err)
return
}
}
}

5
src/pkg/whatsapp/init.go

@ -12,6 +12,7 @@ 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"
"go.mau.fi/whatsmeow/appstate"
@ -149,6 +150,8 @@ func handler(rawEvt interface{}) {
}
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)
if img := evt.Message.GetImageMessage(); img != nil {
if path, err := ExtractMedia(config.PathStorages, img); err != nil {
@ -164,7 +167,7 @@ func handler(rawEvt interface{}) {
_, _ = cli.SendMessage(context.Background(), evt.Info.Sender, &waE2E.Message{Conversation: proto.String(config.WhatsappAutoReplyMessage)})
}
if config.WhatsappWebhook != "" &&
if len(config.WhatsappWebhook) > 0 &&
!strings.Contains(evt.Info.SourceString(), "broadcast") &&
!isFromMySelf(evt.Info.SourceString()) {
go func(evt *events.Message) {

111
src/pkg/whatsapp/utils.go

@ -250,3 +250,114 @@ func buildForwarded(evt *events.Message) bool {
}
return false
}
func ExtractMessageText(evt *events.Message) string {
messageText := evt.Message.GetConversation()
if extendedText := evt.Message.GetExtendedTextMessage(); extendedText != nil {
messageText = extendedText.GetText()
} else if protocolMessage := evt.Message.GetProtocolMessage(); protocolMessage != nil {
if editedMessage := protocolMessage.GetEditedMessage(); editedMessage != nil {
if extendedText := editedMessage.GetExtendedTextMessage(); extendedText != nil {
messageText = extendedText.GetText()
}
}
} else if imageMessage := evt.Message.GetImageMessage(); imageMessage != nil {
messageText = imageMessage.GetCaption()
if messageText == "" {
messageText = "🖼️ Image"
} else {
messageText = "🖼️ " + messageText
}
} else if documentMessage := evt.Message.GetDocumentMessage(); documentMessage != nil {
messageText = documentMessage.GetCaption()
if messageText == "" {
messageText = "📄 Document"
} else {
messageText = "📄 " + messageText
}
} else if videoMessage := evt.Message.GetVideoMessage(); videoMessage != nil {
messageText = videoMessage.GetCaption()
if messageText == "" {
messageText = "🎥 Video"
} else {
messageText = "🎥 " + messageText
}
} else if liveLocationMessage := evt.Message.GetLiveLocationMessage(); liveLocationMessage != nil {
messageText = liveLocationMessage.GetCaption()
if messageText == "" {
messageText = "📍 Live Location"
} else {
messageText = "📍 " + messageText
}
} else if locationMessage := evt.Message.GetLocationMessage(); locationMessage != nil {
messageText = locationMessage.GetName()
if messageText == "" {
messageText = "📍 Location"
} else {
messageText = "📍 " + messageText
}
} else if stickerMessage := evt.Message.GetStickerMessage(); stickerMessage != nil {
messageText = "🎨 Sticker"
if stickerMessage.GetIsAnimated() {
messageText = "✨ Animated Sticker"
}
if stickerMessage.GetAccessibilityLabel() != "" {
messageText += " - " + stickerMessage.GetAccessibilityLabel()
}
} else if contactMessage := evt.Message.GetContactMessage(); contactMessage != nil {
messageText = contactMessage.GetDisplayName()
if messageText == "" {
messageText = "👤 Contact"
} else {
messageText = "👤 " + messageText
}
} else if listMessage := evt.Message.GetListMessage(); listMessage != nil {
messageText = listMessage.GetTitle()
if messageText == "" {
messageText = "📝 List"
} else {
messageText = "📝 " + messageText
}
} else if orderMessage := evt.Message.GetOrderMessage(); orderMessage != nil {
messageText = orderMessage.GetOrderTitle()
if messageText == "" {
messageText = "🛍️ Order"
} else {
messageText = "🛍️ " + messageText
}
} else if paymentMessage := evt.Message.GetPaymentInviteMessage(); paymentMessage != nil {
messageText = paymentMessage.GetServiceType().String()
if messageText == "" {
messageText = "💳 Payment"
} else {
messageText = "💳 " + messageText
}
} else if audioMessage := evt.Message.GetAudioMessage(); audioMessage != nil {
messageText = "🎧 Audio"
if audioMessage.GetPTT() {
messageText = "🎤 Voice Message"
}
} else if pollMessageV3 := evt.Message.GetPollCreationMessageV3(); pollMessageV3 != nil {
messageText = pollMessageV3.GetName()
if messageText == "" {
messageText = "📊 Poll"
} else {
messageText = "📊 " + messageText
}
} else if pollMessageV4 := evt.Message.GetPollCreationMessageV4(); pollMessageV4 != nil {
messageText = pollMessageV4.GetMessage().GetConversation()
if messageText == "" {
messageText = "📊 Poll"
} else {
messageText = "📊 " + messageText
}
} else if pollMessageV5 := evt.Message.GetPollCreationMessageV5(); pollMessageV5 != nil {
messageText = pollMessageV5.GetMessage().GetConversation()
if messageText == "" {
messageText = "📊 Poll"
} else {
messageText = "📊 " + messageText
}
}
return messageText
}

10
src/pkg/whatsapp/webhook.go

@ -21,8 +21,10 @@ func forwardToWebhook(evt *events.Message) error {
return err
}
if err = submitWebhook(payload); err != nil {
return err
for _, url := range config.WhatsappWebhook {
if err = submitWebhook(payload, url); err != nil {
return err
}
}
logrus.Info("Event forwarded to webhook")
@ -126,7 +128,7 @@ func createPayload(evt *events.Message) (map[string]interface{}, error) {
return body, nil
}
func submitWebhook(payload map[string]interface{}) error {
func submitWebhook(payload map[string]interface{}, url string) error {
client := &http.Client{Timeout: 10 * time.Second}
postBody, err := json.Marshal(payload)
@ -134,7 +136,7 @@ func submitWebhook(payload map[string]interface{}) error {
return pkgError.WebhookError(fmt.Sprintf("Failed to marshal body: %v", err))
}
req, err := http.NewRequest(http.MethodPost, config.WhatsappWebhook, bytes.NewBuffer(postBody))
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(postBody))
if err != nil {
return pkgError.WebhookError(fmt.Sprintf("error when create http object %v", err))
}

14
src/services/app.go

@ -4,6 +4,11 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
domainApp "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/app"
pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
@ -14,10 +19,6 @@ import (
"go.mau.fi/libsignal/logger"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/store/sqlstore"
"os"
"path/filepath"
"strings"
"time"
)
type serviceApp struct {
@ -98,11 +99,14 @@ func (service serviceApp) LoginWithCode(ctx context.Context, phoneNumber string)
}
// detect is already logged in
if service.WaCli.IsLoggedIn() {
if service.WaCli.Store.ID != nil {
logrus.Warn("User is already logged in")
return loginCode, pkgError.ErrAlreadyLoggedIn
}
// reconnect first
_ = service.Reconnect(ctx)
loginCode, err = service.WaCli.PairPhone(phoneNumber, true, whatsmeow.PairClientChrome, "Chrome (Linux)")
if err != nil {
logrus.Errorf("Error when pairing phone: %s", err.Error())

27
src/services/message.go

@ -3,6 +3,8 @@ package services
import (
"context"
"fmt"
"time"
"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/whatsapp"
@ -15,7 +17,6 @@ import (
"go.mau.fi/whatsmeow/proto/waSyncAction"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
"time"
)
type serviceMessage struct {
@ -157,3 +158,27 @@ func (service serviceMessage) UpdateMessage(ctx context.Context, request domainM
response.Status = fmt.Sprintf("Update message success %s (server timestamp: %s)", request.Phone, ts.Timestamp)
return response, nil
}
// StarMessage implements message.IMessageService.
func (service serviceMessage) StarMessage(ctx context.Context, request domainMessage.StarRequest) (err error) {
if err = validations.ValidateStarMessage(ctx, request); err != nil {
return err
}
dataWaRecipient, err := whatsapp.ValidateJidWithLogin(service.WaCli, request.Phone)
if err != nil {
return err
}
isFromMe := true
if len(request.MessageID) > 22 {
isFromMe = false
}
patchInfo := appstate.BuildStar(dataWaRecipient.ToNonAD(), *service.WaCli.Store.ID, request.MessageID, isFromMe, request.IsStarred)
if err = service.WaCli.SendAppState(patchInfo); err != nil {
return err
}
return nil
}

116
src/services/send.go

@ -3,6 +3,10 @@ package services
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
"github.com/aldinokemal/go-whatsapp-web-multidevice/domains/app"
domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send"
@ -19,9 +23,6 @@ import (
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
"net/http"
"os"
"os/exec"
)
type serviceSend struct {
@ -36,6 +37,18 @@ func NewSendService(waCli *whatsmeow.Client, appService app.IAppService) domainS
}
}
// wrapSendMessage wraps the message sending process with message ID saving
func (service serviceSend) wrapSendMessage(ctx context.Context, recipient types.JID, msg *waE2E.Message, content string) (whatsmeow.SendResponse, error) {
ts, err := service.WaCli.SendMessage(ctx, recipient, msg)
if err != nil {
return whatsmeow.SendResponse{}, err
}
utils.RecordMessage(ts.ID, service.WaCli.Store.ID.String(), content)
return ts, nil
}
func (service serviceSend) SendText(ctx context.Context, request domainSend.MessageRequest) (response domainSend.GenericResponse, err error) {
err = validations.ValidateSendMessage(ctx, request)
if err != nil {
@ -62,32 +75,28 @@ func (service serviceSend) SendText(ctx context.Context, request domainSend.Mess
// Reply message
if request.ReplyMessageID != nil && *request.ReplyMessageID != "" {
participantJID := dataWaRecipient.String()
if len(*request.ReplyMessageID) < 28 {
firstDevice, err := service.appService.FirstDevice(ctx)
if err != nil {
return response, err
}
participantJID = firstDevice.Device
}
msg.ExtendedTextMessage = &waE2E.ExtendedTextMessage{
Text: proto.String(request.Message),
ContextInfo: &waE2E.ContextInfo{
StanzaID: request.ReplyMessageID,
Participant: proto.String(participantJID),
QuotedMessage: &waE2E.Message{
Conversation: proto.String(request.Message),
record, err := utils.FindRecordFromStorage(*request.ReplyMessageID)
if err == nil { // Only set reply context if we found the message ID
msg.ExtendedTextMessage = &waE2E.ExtendedTextMessage{
Text: proto.String(request.Message),
ContextInfo: &waE2E.ContextInfo{
StanzaID: request.ReplyMessageID,
Participant: proto.String(record.JID),
QuotedMessage: &waE2E.Message{
Conversation: proto.String(record.MessageContent),
},
},
},
}
}
if len(parsedMentions) > 0 {
msg.ExtendedTextMessage.ContextInfo.MentionedJID = parsedMentions
if len(parsedMentions) > 0 {
msg.ExtendedTextMessage.ContextInfo.MentionedJID = parsedMentions
}
} else {
logrus.Warnf("Reply message ID %s not found in storage, continuing without reply context", *request.ReplyMessageID)
}
}
ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg)
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, request.Message)
if err != nil {
return response, err
}
@ -180,7 +189,12 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima
FileLength: proto.Uint64(uint64(len(dataWaImage))),
ViewOnce: proto.Bool(request.ViewOnce),
}}
ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg)
caption := "🖼️ Image"
if request.Caption != "" {
caption = "🖼️ " + request.Caption
}
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, caption)
go func() {
errDelete := utils.RemoveFile(0, deletedItems...)
if errDelete != nil {
@ -228,7 +242,11 @@ func (service serviceSend) SendFile(ctx context.Context, request domainSend.File
DirectPath: proto.String(uploadedFile.DirectPath),
Caption: proto.String(request.Caption),
}}
ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg)
caption := "📄 Document"
if request.Caption != "" {
caption = "📄 " + request.Caption
}
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, caption)
if err != nil {
return response, err
}
@ -336,7 +354,11 @@ func (service serviceSend) SendVideo(ctx context.Context, request domainSend.Vid
ThumbnailSHA256: dataWaThumbnail,
ThumbnailDirectPath: proto.String(uploaded.DirectPath),
}}
ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg)
caption := "🎥 Video"
if request.Caption != "" {
caption = "🎥 " + request.Caption
}
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, caption)
go func() {
errDelete := utils.RemoveFile(1, deletedItems...)
if errDelete != nil {
@ -368,7 +390,10 @@ func (service serviceSend) SendContact(ctx context.Context, request domainSend.C
DisplayName: proto.String(request.ContactName),
Vcard: proto.String(msgVCard),
}}
ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg)
content := "👤 " + request.ContactName
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content)
if err != nil {
return response, err
}
@ -397,7 +422,11 @@ func (service serviceSend) SendLink(ctx context.Context, request domainSend.Link
MatchedText: proto.String(request.Link),
Description: proto.String(getMetaDataFromURL.Description),
}}
ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg)
content := "🔗 " + request.Link
if request.Caption != "" {
content = "🔗 " + request.Caption
}
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content)
if err != nil {
return response, err
}
@ -425,8 +454,10 @@ func (service serviceSend) SendLocation(ctx context.Context, request domainSend.
},
}
content := "📍 " + request.Latitude + ", " + request.Longitude
// Send WhatsApp Message Proto
ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg)
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content)
if err != nil {
return response, err
}
@ -467,7 +498,9 @@ func (service serviceSend) SendAudio(ctx context.Context, request domainSend.Aud
},
}
ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg)
content := "🎵 Audio"
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content)
if err != nil {
return response, err
}
@ -487,7 +520,9 @@ func (service serviceSend) SendPoll(ctx context.Context, request domainSend.Poll
return response, err
}
ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, service.WaCli.BuildPollCreation(request.Question, request.Options, request.MaxAnswer))
content := "📊 " + request.Question
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, service.WaCli.BuildPollCreation(request.Question, request.Options, request.MaxAnswer), content)
if err != nil {
return response, err
}
@ -497,6 +532,22 @@ func (service serviceSend) SendPoll(ctx context.Context, request domainSend.Poll
return response, nil
}
func (service serviceSend) SendPresence(ctx context.Context, request domainSend.PresenceRequest) (response domainSend.GenericResponse, err error) {
err = validations.ValidateSendPresence(ctx, request)
if err != nil {
return response, err
}
err = service.WaCli.SendPresence(types.Presence(request.Type))
if err != nil {
return response, err
}
response.MessageID = "presence"
response.Status = fmt.Sprintf("Send presence success %s", request.Type)
return response, nil
}
func (service serviceSend) getMentionFromText(_ context.Context, messages string) (result []string) {
mentions := utils.ContainsMention(messages)
for _, mention := range mentions {
@ -506,7 +557,6 @@ func (service serviceSend) getMentionFromText(_ context.Context, messages string
}
}
return result
}
func (service serviceSend) uploadMedia(ctx context.Context, mediaType whatsmeow.MediaType, media []byte, recipient types.JID) (uploaded whatsmeow.UploadResponse, err error) {

60
src/services/user.go

@ -1,16 +1,20 @@
package services
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"time"
domainUser "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/user"
pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/whatsapp"
"github.com/aldinokemal/go-whatsapp-web-multidevice/validations"
"github.com/disintegration/imaging"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
"time"
)
type userService struct {
@ -155,3 +159,57 @@ func (service userService) MyPrivacySetting(_ context.Context) (response domainU
response.Profile = string(resp.Profile)
return response, nil
}
func (service userService) ChangeAvatar(ctx context.Context, request domainUser.ChangeAvatarRequest) (err error) {
whatsapp.MustLogin(service.WaCli)
file, err := request.Avatar.Open()
if err != nil {
return err
}
defer file.Close()
// Read original image
srcImage, err := imaging.Decode(file)
if err != nil {
return fmt.Errorf("failed to decode image: %v", err)
}
// Get original dimensions
bounds := srcImage.Bounds()
width := bounds.Dx()
height := bounds.Dy()
// Calculate new dimensions for 1:1 aspect ratio
size := width
if height < width {
size = height
}
if size > 640 {
size = 640
}
// Create a square crop from the center
left := (width - size) / 2
top := (height - size) / 2
croppedImage := imaging.Crop(srcImage, image.Rect(left, top, left+size, top+size))
// Resize if needed
if size > 640 {
croppedImage = imaging.Resize(croppedImage, 640, 640, imaging.Lanczos)
}
// Convert to bytes
var buf bytes.Buffer
err = imaging.Encode(&buf, croppedImage, imaging.JPEG, imaging.JPEGQuality(80))
if err != nil {
return fmt.Errorf("failed to encode image: %v", err)
}
_, err = service.WaCli.SetGroupPhoto(types.JID{}, buf.Bytes())
if err != nil {
return err
}
return nil
}

15
src/validations/message_validation.go

@ -2,6 +2,7 @@ package validations
import (
"context"
"github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message"
domainMessage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message"
pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
@ -75,3 +76,17 @@ func ValidateDeleteMessage(ctx context.Context, request domainMessage.DeleteRequ
return nil
}
func ValidateStarMessage(ctx context.Context, request domainMessage.StarRequest) error {
err := validation.ValidateStructWithContext(ctx, &request,
validation.Field(&request.Phone, validation.Required),
validation.Field(&request.MessageID, validation.Required),
validation.Field(&request.IsStarred, validation.Required),
)
if err != nil {
return pkgError.ValidationError(err.Error())
}
return nil
}

13
src/validations/send_validation.go

@ -3,6 +3,7 @@ package validations
import (
"context"
"fmt"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send"
pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
@ -205,3 +206,15 @@ func ValidateSendPoll(ctx context.Context, request domainSend.PollRequest) error
return nil
}
func ValidateSendPresence(ctx context.Context, request domainSend.PresenceRequest) error {
err := validation.ValidateStructWithContext(ctx, &request,
validation.Field(&request.Type, validation.In("available", "unavailable")),
)
if err != nil {
return pkgError.ValidationError(err.Error())
}
return nil
}

282
src/views/assets/app.css

@ -0,0 +1,282 @@
:root {
--primary-color: #00A884; /* WhatsApp's new brand green */
--secondary-color: #008069; /* WhatsApp's darker green */
--tertiary-color: #075E54; /* WhatsApp's darkest green */
--background-color: #EFEAE2; /* WhatsApp's authentic background */
--card-hover-color: #ffffff;
--text-color: #111B21; /* WhatsApp's text color */
--gradient-start: #00A884;
--gradient-end: #008069;
--message-background: #FFFFFF;
--success-green: #00A884;
}
body {
background-color: var(--background-color) !important;
color: var(--text-color) !important;
font-family: "Helvetica Neue", "Helvetica Neue", Helvetica, Arial, sans-serif !important;
}
.container {
padding: 2em 1em !important;
max-width: 1400px !important;
margin: 0 auto !important;
}
.main-header {
background: var(--primary-color) !important;
color: white !important;
padding: 1.5em 2em !important;
border-radius: 24px !important;
margin-bottom: 2em !important;
box-shadow:
0 20px 40px rgba(0, 168, 132, 0.2),
inset 0 0 80px rgba(255, 255, 255, 0.15) !important;
position: relative;
overflow: hidden;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease-in-out;
}
.main-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(
circle at top right,
rgba(255,255,255,0.2) 0%,
rgba(255,255,255,0) 60%
),
linear-gradient(
45deg,
rgba(255,255,255,0.1) 0%,
rgba(255,255,255,0) 70%
);
opacity: 0.8;
z-index: 1;
}
.main-header::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
right: -50%;
bottom: -50%;
background:
radial-gradient(
circle,
rgba(255,255,255,0.1) 0%,
transparent 70%
);
animation: rotate 20s linear infinite;
z-index: 0;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.main-header .ui.header {
position: relative;
z-index: 2;
margin: 0 !important;
font-weight: 700 !important;
font-size: clamp(1.5em, 3vw, 1.8em) !important;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2) !important;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
letter-spacing: 0.5px;
}
.main-header .title-container {
display: flex;
align-items: center;
gap: 8px;
color: white;
}
.main-header .whatsapp.icon {
font-size: 1em !important;
animation: float 4s ease-in-out infinite;
backdrop-filter: blur(5px);
transition: all 0.3s ease;
}
.main-header .whatsapp.icon:hover {
transform: scale(1.1);
background: rgba(255, 255, 255, 0.2);
}
.main-header .version-label {
margin-top: 4px !important;
background: rgba(255,255,255,0.12);
color: white;
font-size: 0.5em;
padding: 4px 10px;
border-radius: 12px;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.15);
transition: all 0.25s ease;
letter-spacing: 0.5px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.main-header .version-label:hover {
background: rgba(255,255,255,0.2);
transform: translateY(-2px);
}
@media (min-width: 768px) {
.main-header {
padding: 2em !important;
}
.main-header .ui.header {
flex-direction: column;
}
.main-header .version-label {
margin-top: 4px !important;
}
}
@keyframes float {
0% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-8px) rotate(5deg); }
100% { transform: translateY(0px) rotate(0deg); }
}
.ui.header {
font-weight: 600 !important;
color: var(--text-color) !important;
}
.ui.cards > .card {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05) !important;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important;
border-radius: 16px !important;
margin: 0.7em !important;
background-color: var(--message-background) !important;
border: 1px solid rgba(18, 140, 126, 0.1);
}
.ui.cards > .card:hover {
transform: translateY(-8px) !important;
box-shadow: 0 12px 24px rgba(18, 140, 126, 0.15) !important;
background-color: var(--card-hover-color) !important;
}
.ui.horizontal.divider {
font-size: 1.3em !important;
color: var(--secondary-color) !important;
margin: 2.5em 0 !important;
font-weight: 700 !important;
text-transform: uppercase;
letter-spacing: 1px;
}
.ui.success.message {
border-radius: 16px !important;
box-shadow: 0 4px 8px rgba(37, 211, 102, 0.1) !important;
border: 1px solid rgba(37, 211, 102, 0.2) !important;
background-color: rgba(231, 247, 232, 0.8) !important;
color: var(--success-green) !important;
animation: slideIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
backdrop-filter: blur(10px);
}
@keyframes slideIn {
from { transform: translateX(-30px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.ui.button {
border-radius: 12px !important;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important;
/* background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%) !important; */
background: var(--primary-color) !important;
color: white !important;
font-weight: 600 !important;
letter-spacing: 0.5px;
}
.ui.button:hover {
transform: translateY(-3px) !important;
box-shadow: 0 8px 16px rgba(0, 168, 132, 0.2) !important;
filter: brightness(0.95);
}
.ui.form input, .ui.form textarea {
border-radius: 12px !important;
border: 2px solid rgba(18, 140, 126, 0.1) !important;
transition: all 0.3s ease;
}
.ui.form input:focus, .ui.form textarea:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 3px rgba(0, 168, 132, 0.1) !important;
}
.ui.toast-container {
padding: 1.5em !important;
}
.ui.toast {
border-radius: 16px !important;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1) !important;
background-color: var(--message-background) !important;
border: 1px solid rgba(18, 140, 126, 0.1);
}
.ui.grid {
margin: -0.7em !important;
}
.ui.grid > .column {
padding: 0.7em !important;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.ui.cards {
animation: fadeIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.ui.modal {
border-radius: 20px !important;
background-color: var(--message-background) !important;
overflow: hidden;
}
.ui.modal > .header {
border-radius: 20px 20px 0 0 !important;
background: var(--gradient-start) !important;
color: white !important;
padding: 1.5em !important;
}
.ui.modal > .actions {
border-radius: 0 0 20px 20px !important;
background-color: rgba(248, 248, 248, 0.8) !important;
backdrop-filter: blur(10px);
}
.ui.modal > .actions > .ui.button {
background: var(--primary-color) !important;
color: white !important;
font-weight: 600 !important;
letter-spacing: 0.5px;
}

16
src/views/components/AccountAvatar.js

@ -25,7 +25,18 @@ export default {
this.handleReset();
$('#modalUserAvatar').modal('show');
},
isValidForm() {
if (!this.phone.trim()) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
await this.submitApi();
showSuccessInfo("Avatar fetched")
@ -56,6 +67,7 @@ export default {
template: `
<div class="green card" @click="openModal" style="cursor: pointer;">
<div class="content">
<a class="ui olive right ribbon label">Account</a>
<div class="header">Avatar</div>
<div class="description">
You can search someone avatar by phone
@ -89,8 +101,8 @@ export default {
</div>
</div>
<button type="button" class="ui primary button" :class="{'loading': loading}"
@click="handleSubmit">
<button type="button" class="ui primary button" :class="{'loading': loading, 'disabled': !this.isValidForm() || this.loading}"
@click.prevent="handleSubmit">
Search
</button>
</form>

113
src/views/components/AccountChangeAvatar.js

@ -0,0 +1,113 @@
export default {
name: 'AccountChangeAvatar',
data() {
return {
loading: false,
selected_file: null,
preview_url: null
}
},
methods: {
openModal() {
$('#modalChangeAvatar').modal({
onApprove: function () {
return false;
}
}).modal('show');
},
isValidForm() {
return this.selected_file !== null;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
$('#modalChangeAvatar').modal('hide');
} catch (err) {
showErrorInfo(err)
}
},
async submitApi() {
this.loading = true;
try {
let payload = new FormData();
payload.append('avatar', $("#file_avatar")[0].files[0])
let response = await window.http.post(`/user/avatar`, payload)
this.handleReset();
return response.data.message;
} catch (error) {
if (error.response) {
throw new Error(error.response.data.message);
}
throw new Error(error.message);
} finally {
this.loading = false;
}
},
handleReset() {
this.preview_url = null;
this.selected_file = null;
$("#file_avatar").val('');
},
handleImageChange(event) {
const file = event.target.files[0];
if (file) {
this.preview_url = URL.createObjectURL(file);
this.selected_file = file.name;
}
}
},
template: `
<div class="blue card" @click="openModal()" style="cursor:pointer;">
<div class="content">
<a class="ui olive right ribbon label">Account</a>
<div class="header">Change Avatar</div>
<div class="description">
Update your profile picture
</div>
</div>
</div>
<!-- Modal Change Avatar -->
<div class="ui small modal" id="modalChangeAvatar">
<i class="close icon"></i>
<div class="header">
Change Avatar
</div>
<div class="content" style="max-height: 70vh; overflow-y: auto;">
<div class="ui warning message">
<i class="info circle icon"></i>
Please upload a square image (1:1 aspect ratio) to avoid cropping.
For best results, use an image at least 400x400 pixels.
</div>
<form class="ui form">
<div class="field" style="padding-bottom: 30px">
<label>Avatar Image</label>
<input type="file" style="display: none" id="file_avatar" accept="image/png,image/jpg,image/jpeg" @change="handleImageChange"/>
<label for="file_avatar" class="ui positive medium green left floated button" style="color: white">
<i class="ui upload icon"></i>
Upload image
</label>
<div v-if="preview_url" style="margin-top: 60px">
<img :src="preview_url" style="max-width: 100%; max-height: 300px; object-fit: contain" />
</div>
</div>
</form>
</div>
<div class="actions">
<button class="ui approve positive right labeled icon button"
:class="{'loading': this.loading, 'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Update Avatar
<i class="save icon"></i>
</button>
</div>
</div>
`
}

1
src/views/components/AccountPrivacy.js

@ -30,6 +30,7 @@ export default {
template: `
<div class="green 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>
<div class="description">
Get your privacy settings

16
src/views/components/AccountUserInfo.js

@ -28,7 +28,18 @@ export default {
this.handleReset();
$('#modalUserInfo').modal('show');
},
isValidForm() {
if (!this.phone.trim()) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
await this.submitApi();
showSuccessInfo("Info fetched")
@ -63,6 +74,7 @@ export default {
template: `
<div class="green card" @click="openModal" style="cursor: pointer;">
<div class="content">
<a class="ui olive right ribbon label">Account</a>
<div class="header">User Info</div>
<div class="description">
You can search someone user info by phone
@ -81,8 +93,8 @@ export default {
<form class="ui form">
<FormRecipient v-model:type="type" v-model:phone="phone"/>
<button type="button" class="ui primary button" :class="{'loading': loading}"
@click="handleSubmit">
<button type="button" class="ui primary button" :class="{'loading': loading, 'disabled': !this.isValidForm() || this.loading}"
@click.prevent="handleSubmit">
Search
</button>
</form>

1
src/views/components/AppLogin.js

@ -41,6 +41,7 @@ export default {
template: `
<div class="green card" @click="openModal" style="cursor: pointer">
<div class="content">
<a class="ui teal right ribbon label">App</a>
<div class="header">Login</div>
<div class="description">
Scan your QR code to access all API capabilities.

5
src/views/components/AppLoginWithCode.js

@ -2,8 +2,8 @@ export default {
name: 'AppLoginWithCode',
props: {
connected: {
type: Boolean,
default: false,
type: Array,
default: [],
}
},
watch: {
@ -62,6 +62,7 @@ export default {
template: `
<div class="green card" @click="openModal" style="cursor: pointer">
<div class="content">
<a class="ui teal right ribbon label">App</a>
<div class="header">Login with Code</div>
<div class="description">
Enter your pairing code to log in and access your devices.

1
src/views/components/AppLogout.js

@ -28,6 +28,7 @@ export default {
template: `
<div class="green card" @click="handleSubmit" style="cursor: pointer">
<div class="content">
<a class="ui teal right ribbon label">App</a>
<div class="header">Logout</div>
<div class="description">
Remove your login session in application

1
src/views/components/AppReconnect.js

@ -26,6 +26,7 @@ export default {
template: `
<div class="green card" @click="handleSubmit" style="cursor: pointer">
<div class="content">
<a class="ui teal right ribbon label">App</a>
<div class="header">Reconnect</div>
<div class="description">
Please reconnect to the WhatsApp service if your API doesn't work or if your app is down.

20
src/views/components/GroupCreate.js

@ -15,6 +15,17 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (!this.title.trim()) {
return false;
}
if (this.participants.length < 1 || this.participants.every(participant => !participant.trim())) {
return false;
}
return true;
},
handleAddParticipant() {
this.participants.push('')
},
@ -22,6 +33,9 @@ export default {
this.participants.splice(index, 1)
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -103,11 +117,11 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit" type="button">
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !this.isValidForm() || this.loading}"
@click.prevent="handleSubmit" type="button">
Create
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

23
src/views/components/GroupJoinWithLink.js

@ -14,7 +14,24 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (!this.link.trim()) {
return false;
}
// should valid URL
try {
new URL(this.link);
} catch (error) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -72,11 +89,11 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit" type="button">
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !this.isValidForm() || this.loading}"
@click.prevent="handleSubmit" type="button">
Join
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

17
src/views/components/GroupManageParticipants.js

@ -21,6 +21,13 @@ export default {
},
}).modal('show');
},
isValidForm() {
if (this.participants.length < 1 || this.participants.every(participant => !participant.trim())) {
return false;
}
return true;
},
handleAddParticipant() {
this.participants.push('');
},
@ -28,6 +35,10 @@ export default {
this.participants.splice(index, 1);
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi();
showSuccessInfo(response);
@ -137,11 +148,11 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit" type="button">
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !this.isValidForm() || this.loading}"
@click.prevent="handleSubmit" type="button">
Submit
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`,

21
src/views/components/MessageDelete.js

@ -26,7 +26,22 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
}
if (!this.message_id.trim()) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -89,11 +104,11 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit">
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Delete
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

18
src/views/components/MessageReact.js

@ -27,7 +27,21 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
}
if (!this.message_id.trim() || !this.emoji.trim()) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -94,11 +108,11 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !this.isValidForm() || this.loading}"
@click="handleSubmit">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

18
src/views/components/MessageRevoke.js

@ -26,7 +26,21 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
}
if (!this.message_id.trim()) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -86,11 +100,11 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !isValidForm() || loading}"
@click="handleSubmit">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

18
src/views/components/MessageUpdate.js

@ -27,7 +27,21 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
}
if (!this.message_id.trim() || !this.new_message.trim()) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -95,11 +109,11 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !this.isValidForm() || this.loading}"
@click="handleSubmit">
Update
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

37
src/views/components/SendAudio.js

@ -10,6 +10,7 @@ export default {
phone: '',
type: window.TYPEUSER,
loading: false,
selectedFileName: null
}
},
computed: {
@ -25,7 +26,22 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
}
if (!this.selectedFileName) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -56,7 +72,14 @@ export default {
this.phone = '';
this.type = window.TYPEUSER;
$("#file_audio").val('');
this.selectedFileName = null;
},
handleFileChange(event) {
const file = event.target.files[0];
if (file) {
this.selectedFileName = file.name;
}
}
},
template: `
<div class="blue card" @click="openModal()" style="cursor: pointer">
@ -80,20 +103,26 @@ export default {
<FormRecipient v-model:type="type" v-model:phone="phone"/>
<div class="field" style="padding-bottom: 30px">
<label>Audio</label>
<input type="file" style="display: none" accept="audio/*" id="file_audio"/>
<input type="file" style="display: none" accept="audio/*" id="file_audio" @change="handleFileChange"/>
<label for="file_audio" class="ui positive medium green left floated button" style="color: white">
<i class="ui upload icon"></i>
Upload
</label>
<div v-if="selectedFileName" style="margin-top: 60px">
<div class="ui message">
<i class="file icon"></i>
Selected file: {{ selectedFileName }}
</div>
</div>
</div>
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit">
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

25
src/views/components/SendContact.js

@ -27,6 +27,21 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
}
if (!this.card_name.trim()) {
return false;
}
if (!this.card_phone.trim()) {
return false;
}
return true;
},
async handleSubmit() {
try {
this.loading = true;
@ -40,6 +55,10 @@ export default {
}
},
async submitApi() {
if (!this.isValidForm() || this.loading) {
return;
}
this.loading = true;
try {
const payload = {
@ -100,11 +119,11 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit">
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

37
src/views/components/SendFile.js

@ -17,6 +17,7 @@ export default {
type: window.TYPEUSER,
phone: '',
loading: false,
selectedFileName: null
}
},
computed: {
@ -32,7 +33,22 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
}
if (!this.selectedFileName) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -64,8 +80,15 @@ export default {
this.caption = '';
this.phone = '';
this.type = window.TYPEUSER;
this.selectedFileName = null;
$("#file_file").val('');
},
handleFileChange(event) {
const file = event.target.files[0];
if (file) {
this.selectedFileName = file.name;
}
}
},
template: `
<div class="blue card" @click="openModal()" style="cursor: pointer">
@ -96,20 +119,26 @@ export default {
</div>
<div class="field" style="padding-bottom: 30px">
<label>File</label>
<input type="file" style="display: none" id="file_file">
<input type="file" style="display: none" id="file_file" @change="handleFileChange">
<label for="file_file" class="ui positive medium green left floated button" style="color: white">
<i class="ui upload icon"></i>
Upload file
</label>
<div v-if="selectedFileName" style="margin-top: 60px; clear: both;">
<div class="ui message">
<i class="file icon"></i>
Selected file: {{ selectedFileName }}
</div>
</div>
</div>
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit">
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

60
src/views/components/SendImage.js

@ -13,13 +13,14 @@ export default {
caption: '',
type: window.TYPEUSER,
loading: false,
selected_file: null
selected_file: null,
preview_url: null
}
},
computed: {
phone_id() {
return this.phone + this.type;
}
},
},
methods: {
openModal() {
@ -29,7 +30,25 @@ export default {
}
}).modal('show');
},
isShowAttributes() {
return this.type !== window.TYPESTATUS;
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
}
if (!this.selected_file) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -65,9 +84,24 @@ export default {
this.compress = false;
this.phone = '';
this.caption = '';
this.type = window.TYPEUSER;
this.preview_url = null;
this.selected_file = null;
$("#file_image").val('');
},
handleImageChange(event) {
const file = event.target.files[0];
if (file) {
this.preview_url = URL.createObjectURL(file);
// Add small delay to allow DOM update before scrolling
setTimeout(() => {
const modalContent = document.querySelector('#modalSendImage .content');
if (modalContent) {
modalContent.scrollTop = modalContent.scrollHeight;
}
this.selected_file = file.name;
}, 100);
}
}
},
template: `
<div class="blue card" @click="openModal()" style="cursor:pointer;">
@ -88,23 +122,23 @@ export default {
<div class="header">
Send Image
</div>
<div class="content">
<div class="content" style="max-height: 70vh; overflow-y: auto;">
<form class="ui form">
<FormRecipient v-model:type="type" v-model:phone="phone"/>
<FormRecipient v-model:type="type" v-model:phone="phone" :show-status="true"/>
<div class="field">
<label>Caption</label>
<textarea v-model="caption" type="text" placeholder="Hello this is image caption"
aria-label="caption"></textarea>
</div>
<div class="field">
<div class="field" v-if="isShowAttributes()">
<label>View Once</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="view once" v-model="view_once">
<label>Check for enable one time view</label>
</div>
</div>
<div class="field">
<div class="field" v-if="isShowAttributes()">
<label>Compress</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="compress" v-model="compress">
@ -113,20 +147,24 @@ export default {
</div>
<div class="field" style="padding-bottom: 30px">
<label>Image</label>
<input type="file" style="display: none" id="file_image" accept="image/png,image/jpg,image/jpeg"/>
<input type="file" style="display: none" id="file_image" accept="image/png,image/jpg,image/jpeg" @change="handleImageChange"/>
<label for="file_image" class="ui positive medium green left floated button" style="color: white">
<i class="ui upload icon"></i>
Upload image
</label>
<div v-if="preview_url" style="margin-top: 60px">
<img :src="preview_url" style="max-width: 100%; max-height: 300px; object-fit: contain" />
</div>
</div>
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit">
<button class="ui approve positive right labeled icon button"
:class="{'loading': this.loading, 'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

26
src/views/components/SendLocation.js

@ -17,6 +17,22 @@ export default {
computed: {
phone_id() {
return this.phone + this.type;
},
isValidForm() {
// Validate phone number is not empty except for status type
const isPhoneValid = this.type === window.TYPESTATUS || this.phone.trim().length > 0;
// Validate latitude is between -90 and 90
const isLatitudeValid = !isNaN(this.latitude) &&
parseFloat(this.latitude) >= -90 &&
parseFloat(this.latitude) <= 90;
// Validate longitude is between -180 and 180
const isLongitudeValid = !isNaN(this.longitude) &&
parseFloat(this.longitude) >= -180 &&
parseFloat(this.longitude) <= 180;
return isPhoneValid && isLatitudeValid && isLongitudeValid;
}
},
methods: {
@ -87,22 +103,22 @@ export default {
<div class="field">
<label>Location Latitude</label>
<input v-model="latitude" type="text" placeholder="Please enter latitude"
<input v-model="latitude" type="text" placeholder="Please enter latitude (-90 to 90)"
aria-label="latitude">
</div>
<div class="field">
<label>Location Longitude</label>
<input v-model="longitude" type="text" placeholder="Please enter longitude"
<input v-model="longitude" type="text" placeholder="Please enter longitude (-180 to 180)"
aria-label="longitude">
</div>
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit">
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit" :disabled="!isValidForm">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

50
src/views/components/SendMessage.js

@ -17,7 +17,7 @@ export default {
computed: {
phone_id() {
return this.phone + this.type;
}
},
},
methods: {
openModal() {
@ -27,13 +27,33 @@ export default {
}
}).modal('show');
},
isShowReplyId() {
return this.type !== window.TYPESTATUS;
},
isValidForm() {
// Validate phone number is not empty except for status type
const isPhoneValid = this.type === window.TYPESTATUS || this.phone.trim().length > 0;
// Validate message is not empty and has reasonable length
const isMessageValid = this.text.trim().length > 0 && this.text.length <= 4096;
// Validate reply_message_id format if provided
const isReplyIdValid = this.reply_message_id === '' ||
/^[A-F0-9]{32}\/[A-F0-9]{20}$/.test(this.reply_message_id);
return isPhoneValid && isMessageValid && isReplyIdValid;
},
async handleSubmit() {
// Add validation check here to prevent submission when form is invalid
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
const response = await this.submitApi();
showSuccessInfo(response);
$('#modalSendMessage').modal('hide');
} catch (err) {
showErrorInfo(err)
showErrorInfo(err);
}
},
async submitApi() {
@ -41,20 +61,20 @@ export default {
try {
const payload = {
phone: this.phone_id,
message: this.text,
}
message: this.text.trim(),
};
if (this.reply_message_id !== '') {
payload.reply_message_id = this.reply_message_id;
}
let response = await window.http.post(`/send/message`, payload)
const response = await window.http.post('/send/message', payload);
this.handleReset();
return response.data.message;
} catch (error) {
if (error.response) {
if (error.response?.data?.message) {
throw new Error(error.response.data.message);
}
throw new Error(error.message);
throw error;
} finally {
this.loading = false;
}
@ -62,7 +82,6 @@ export default {
handleReset() {
this.phone = '';
this.text = '';
this.type = window.TYPEUSER;
this.reply_message_id = '';
},
},
@ -85,8 +104,8 @@ export default {
</div>
<div class="content">
<form class="ui form">
<FormRecipient v-model:type="type" v-model:phone="phone"/>
<div class="field">
<FormRecipient v-model:type="type" v-model:phone="phone" :show-status="true"/>
<div class="field" v-if="isShowReplyId()">
<label>Reply Message ID</label>
<input v-model="reply_message_id" type="text"
placeholder="Optional: 57D29F74B7FC62F57D8AC2C840279B5B/3EB0288F008D32FCD0A424"
@ -100,11 +119,12 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit">
<button class="ui approve positive right labeled icon button"
:class="{'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

25
src/views/components/SendPoll.js

@ -29,7 +29,26 @@ export default {
}
}).modal('show');
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
}
if (!this.question.trim()) {
return false;
}
if (this.options.some(option => option.trim() === '')) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
window.showSuccessInfo(response)
@ -124,11 +143,11 @@ export default {
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit" type="button">
<button class="ui approve positive right labeled icon button" :class="{'loading': this.loading, 'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

86
src/views/components/SendPresence.js

@ -0,0 +1,86 @@
export default {
name: 'SendPresence',
data() {
return {
type: 'available',
loading: false,
}
},
methods: {
openModal() {
$('#modalSendPresence').modal({
onApprove: function () {
return false;
}
}).modal('show');
},
async handleSubmit() {
if (this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
$('#modalSendPresence').modal('hide');
} catch (err) {
showErrorInfo(err)
}
},
async submitApi() {
this.loading = true;
try {
let payload = {
type: this.type
}
let response = await window.http.post(`/send/presence`, payload)
return response.data.message;
} catch (error) {
if (error.response) {
throw new Error(error.response.data.message);
}
throw new Error(error.message);
} finally {
this.loading = false;
}
}
},
template: `
<div class="blue card" @click="openModal()" style="cursor: pointer">
<div class="content">
<a class="ui blue right ribbon label">Send</a>
<div class="header">Send Presence</div>
<div class="description">
Set <div class="ui green horizontal label">available</div> or <div class="ui grey horizontal label">unavailable</div>
</div>
</div>
</div>
<!-- Modal SendPresence -->
<div class="ui small modal" id="modalSendPresence">
<i class="close icon"></i>
<div class="header">
Send Presence
</div>
<div class="content">
<form class="ui form">
<div class="field">
<label>Presence Status</label>
<select v-model="type" class="ui dropdown">
<option value="available">Available</option>
<option value="unavailable">Unavailable</option>
</select>
</div>
</form>
</div>
<div class="actions">
<button class="ui approve positive right labeled icon button"
:class="{'loading': loading, 'disabled': loading}"
@click.prevent="handleSubmit">
Send
<i class="send icon"></i>
</button>
</div>
</div>
`
}

64
src/views/components/SendVideo.js

@ -5,7 +5,6 @@ export default {
components: {
FormRecipient
},
// define props
props: {
maxVideoSize: {
type: String,
@ -20,12 +19,13 @@ export default {
type: window.TYPEUSER,
phone: '',
loading: false,
selectedFileName: null,
}
},
computed: {
phone_id() {
return this.phone + this.type;
}
},
},
methods: {
openModal() {
@ -35,7 +35,36 @@ export default {
}
}).modal('show');
},
isShowAttributes() {
return this.type !== window.TYPESTATUS;
},
isValidForm() {
let isValid = true;
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
isValid = false;
}
const fileInput = $("#file_video")[0];
if (!fileInput || !fileInput.files || !fileInput.files[0]) {
console.log('fileInput', fileInput);
isValid = false;
} else {
const videoFile = fileInput.files[0];
// Validate file type
if (!videoFile.type.startsWith('video/')) {
isValid = false;
console.log('videoFile', videoFile);
}
}
return isValid;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -49,7 +78,7 @@ export default {
try {
let payload = new FormData();
payload.append("phone", this.phone_id)
payload.append("caption", this.caption)
payload.append("caption", this.caption.trim())
payload.append("view_once", this.view_once)
payload.append("compress", this.compress)
payload.append('video', $("#file_video")[0].files[0])
@ -70,9 +99,15 @@ export default {
this.view_once = false;
this.compress = false;
this.phone = '';
this.type = window.TYPEUSER;
this.selectedFileName = null;
$("#file_video").val('');
},
handleFileChange(event) {
const file = event.target.files[0];
if (file) {
this.selectedFileName = file.name;
}
}
},
template: `
<div class="blue card" @click="openModal()" style="cursor: pointer">
@ -96,21 +131,21 @@ export default {
</div>
<div class="content">
<form class="ui form">
<FormRecipient v-model:type="type" v-model:phone="phone"/>
<FormRecipient v-model:type="type" v-model:phone="phone" :show-status="true"/>
<div class="field">
<label>Caption</label>
<textarea v-model="caption" placeholder="Type some caption (optional)..."
aria-label="caption"></textarea>
</div>
<div class="field">
<div class="field" v-if="isShowAttributes()">
<label>View Once</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="view once" v-model="view_once">
<label>Check for enable one time view</label>
</div>
</div>
<div class="field">
<div class="field" v-if="isShowAttributes()">
<label>Compress</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="compress" v-model="compress">
@ -119,20 +154,27 @@ export default {
</div>
<div class="field" style="padding-bottom: 30px">
<label>Video</label>
<input type="file" style="display: none" accept="video/*" id="file_video">
<input type="file" style="display: none" accept="video/*" id="file_video" @change="handleFileChange">
<label for="file_video" class="ui positive medium green left floated button" style="color: white">
<i class="ui upload icon"></i>
Upload video
</label>
<div v-if="selectedFileName" style="margin-top: 60px">
<div class="ui message">
<i class="file icon"></i>
Selected file: {{ selectedFileName }}
</div>
</div>
</div>
</form>
</div>
<div class="actions">
<div class="ui approve positive right labeled icon button" :class="{'loading': this.loading}"
@click="handleSubmit">
<button class="ui approve positive right labeled icon button"
:class="{'loading': loading, 'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Send
<i class="send icon"></i>
</div>
</button>
</div>
</div>
`

25
src/views/components/generic/FormRecipient.js

@ -9,6 +9,10 @@ export default {
type: String,
required: true
},
showStatus: {
type: Boolean,
default: false
}
},
data() {
return {
@ -18,18 +22,33 @@ export default {
computed: {
phone_id() {
return this.phone + this.type;
},
showPhoneInput() {
return this.type !== window.TYPESTATUS;
},
filteredRecipientTypes() {
return this.recipientTypes.filter(type => {
if (!this.showStatus && type.value === window.TYPESTATUS) {
return false;
}
return true;
});
}
},
mounted() {
this.recipientTypes = [
{ value: window.TYPEUSER, text: 'Private Message' },
{ value: window.TYPEGROUP, text: 'Group Message' },
{ value: window.TYPENEWSLETTER, text: 'Newsletter' }
{ value: window.TYPENEWSLETTER, text: 'Newsletter' },
{ value: window.TYPESTATUS, text: 'Status' }
];
},
methods: {
updateType(event) {
this.$emit('update:type', event.target.value);
if (event.target.value === window.TYPESTATUS) {
this.$emit('update:phone', '');
}
},
updatePhone(event) {
this.$emit('update:phone', event.target.value);
@ -39,11 +58,11 @@ export default {
<div class="field">
<label>Type</label>
<select name="type" @change="updateType" class="ui dropdown">
<option v-for="type in recipientTypes" :value="type.value">{{ type.text }}</option>
<option v-for="type in filteredRecipientTypes" :value="type.value">{{ type.text }}</option>
</select>
</div>
<div class="field">
<div v-if="showPhoneInput" class="field">
<label>Phone / Group ID</label>
<input :value="phone" aria-label="wa identifier" @input="updatePhone">
<input :value="phone_id" disabled aria-label="whatsapp_id">

102
src/views/index.html

@ -8,6 +8,47 @@
<link rel="stylesheet" type="text/css"
href="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.8.8/semantic.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.11.4/css/dataTables.semanticui.min.css">
<link rel="stylesheet" href="assets/app.css">
<style>
#splash-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #00A884 0%, #008069 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
transition: opacity 0.5s ease-in-out;
color: white;
}
#splash-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.splash-icon {
font-size: 5em;
color: white;
margin-bottom: 20px;
animation: bounce 2s infinite;
}
.splash-spinner {
margin-top: 20px;
}
.splash-title {
font-size: 2em;
font-weight: bold;
margin: 10px 0;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-20px); }
}
</style>
<script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
<script src="https://cdn.datatables.net/1.11.4/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.11.4/js/dataTables.semanticui.min.js"></script>
@ -15,21 +56,33 @@
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/axios@1.1.2/dist/axios.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js"></script>
<title>Whatsapp API Multi Device {{ .AppVersion }}</title>
<style>
.container {
padding-top: 2em;
}
</style>
<title>WhatsApp API {{ .AppVersion }}</title>
</head>
<body>
<div class="ui container" id="app">
<h1 class="ui header center aligned">Whatsapp API Multi Device ({{ .AppVersion }})</h1>
<div id="splash-screen">
<i class="whatsapp icon splash-icon"></i>
<h2 class="splash-title">WhatsApp API</h2>
<div class="ui active large inline loader splash-spinner"></div>
</div>
<div class="ui container" id="app" style="display: none;">
<div class="main-header">
<h1 class="ui header center aligned">
<div class="title-container">
<i class="whatsapp icon"></i>
WhatsApp API
<div class="version-label">
{{ .AppVersion }}
</div>
</div>
</h1>
</div>
<div class="ui success message" v-if="connected_devices != null">
<div class="header">
Device has connected
<i class="check circle icon"></i>
Device Connected Successfully
</div>
<p>
Device ID: <b>[[ connected_devices[0].device ]]</b>
@ -62,6 +115,7 @@
<send-audio></send-audio>
<send-poll></send-poll>
<send-presence></send-presence>
</div>
<div class="ui horizontal divider">
@ -100,6 +154,7 @@
<div class="ui three column doubling grid cards">
<account-avatar></account-avatar>
<account-change-avatar></account-change-avatar>
<account-user-info></account-user-info>
<account-privacy></account-privacy>
</div>
@ -109,6 +164,7 @@
window.TYPEGROUP = "@g.us";
window.TYPEUSER = "@s.whatsapp.net";
window.TYPENEWSLETTER = "@newsletter";
window.TYPESTATUS = "status@broadcast";
window.showErrorInfo = (message) => {
$('body').toast({
position: 'bottom right',
@ -148,6 +204,7 @@
import SendLocation from "./components/SendLocation.js";
import SendAudio from "./components/SendAudio.js";
import SendPoll from "./components/SendPoll.js";
import SendPresence from "./components/SendPresence.js";
import MessageDelete from "./components/MessageDelete.js";
import MessageUpdate from "./components/MessageUpdate.js";
import MessageReact from "./components/MessageReact.js";
@ -157,6 +214,7 @@
import GroupJoinWithLink from "./components/GroupJoinWithLink.js";
import GroupAddParticipants from "./components/GroupManageParticipants.js";
import AccountAvatar from "./components/AccountAvatar.js";
import AccountChangeAvatar from "./components/AccountChangeAvatar.js";
import AccountUserInfo from "./components/AccountUserInfo.js";
import AccountPrivacy from "./components/AccountPrivacy.js";
import NewsletterList from "./components/NewsletterList.js";
@ -194,11 +252,11 @@
Vue.createApp({
components: {
AppLogin, AppLoginWithCode, AppLogout, AppReconnect,
SendMessage, SendImage, SendFile, SendVideo, SendContact, SendLocation, SendAudio, SendPoll,
SendMessage, SendImage, SendFile, SendVideo, SendContact, SendLocation, SendAudio, SendPoll, SendPresence,
MessageDelete, MessageUpdate, MessageReact, MessageRevoke,
GroupList, GroupCreate, GroupJoinWithLink, GroupAddParticipants,
NewsletterList,
AccountAvatar, AccountUserInfo, AccountPrivacy
AccountAvatar, AccountUserInfo, AccountPrivacy, AccountChangeAvatar
},
delimiters: ['[[', ']]'],
data() {
@ -213,6 +271,9 @@
}
},
mounted() {
// Initialize app container as hidden
document.getElementById('app').style.display = 'none';
if (window["WebSocket"]) {
this.app_ws = new WebSocket(constructWebSocketURL());
@ -220,13 +281,24 @@
this.app_ws.send(JSON.stringify({
"code": "FETCH_DEVICES",
"message": "List device"
}))
}));
// Show app container and hide splash screen with a slight delay
setTimeout(() => {
document.getElementById('app').style.display = 'block';
document.getElementById('splash-screen').classList.add('fade-out');
}, 1000);
};
this.app_ws.onerror = (error) => {
console.error('WebSocket error:', error);
showErrorInfo('Connection error occurred. Please refresh the page.');
};
// Hide splash screen and show error state
setTimeout(() => {
document.getElementById('app').style.display = 'block';
document.getElementById('splash-screen').classList.add('fade-out');
}, 1000);
};
this.app_ws.onmessage = (evt) => {
const message = JSON.parse(evt.data)
@ -247,6 +319,8 @@
};
} else {
console.error('Your browser does not support WebSockets');
// Hide splash screen if WebSocket is not supported
document.getElementById('splash-screen').classList.add('fade-out');
}
},
}).mount('#app')

Loading…
Cancel
Save