Browse Source

fix: adjust merge [release511]

pull/231/head
Ruan 1 year ago
parent
commit
72eca67c83
  1. 96
      docs/openapi.yaml
  2. 170
      readme.md
  3. 20
      src/cmd/root.go
  4. 25
      src/config/settings.go
  5. 7
      src/domains/message/message.go
  6. 2
      src/domains/send/audio.go
  7. 1
      src/domains/send/image.go
  8. 5
      src/domains/send/presence.go
  9. 1
      src/domains/send/send.go
  10. 9
      src/domains/user/account.go
  11. 1
      src/domains/user/user.go
  12. 21
      src/go.mod
  13. 33
      src/go.sum
  14. 46
      src/internal/rest/helpers/flushChatCsv.go
  15. 41
      src/internal/rest/message.go
  16. 22
      src/internal/rest/send.go
  17. 19
      src/internal/rest/user.go
  18. 96
      src/pkg/utils/chat_storage.go
  19. 76
      src/pkg/utils/general.go
  20. 88
      src/pkg/utils/general_test.go
  21. 5
      src/pkg/whatsapp/init.go
  22. 111
      src/pkg/whatsapp/utils.go
  23. 10
      src/pkg/whatsapp/webhook.go
  24. 14
      src/services/app.go
  25. 27
      src/services/message.go
  26. 158
      src/services/send.go
  27. 60
      src/services/user.go
  28. 15
      src/validations/message_validation.go
  29. 75
      src/validations/send_validation.go
  30. 187
      src/validations/send_validation_test.go
  31. 282
      src/views/assets/app.css
  32. 16
      src/views/components/AccountAvatar.js
  33. 113
      src/views/components/AccountChangeAvatar.js
  34. 1
      src/views/components/AccountPrivacy.js
  35. 16
      src/views/components/AccountUserInfo.js
  36. 1
      src/views/components/AppLogin.js
  37. 5
      src/views/components/AppLoginWithCode.js
  38. 1
      src/views/components/AppLogout.js
  39. 1
      src/views/components/AppReconnect.js
  40. 20
      src/views/components/GroupCreate.js
  41. 23
      src/views/components/GroupJoinWithLink.js
  42. 17
      src/views/components/GroupManageParticipants.js
  43. 21
      src/views/components/MessageDelete.js
  44. 18
      src/views/components/MessageReact.js
  45. 18
      src/views/components/MessageRevoke.js
  46. 18
      src/views/components/MessageUpdate.js
  47. 37
      src/views/components/SendAudio.js
  48. 25
      src/views/components/SendContact.js
  49. 37
      src/views/components/SendFile.js
  50. 80
      src/views/components/SendImage.js
  51. 26
      src/views/components/SendLocation.js
  52. 46
      src/views/components/SendMessage.js
  53. 25
      src/views/components/SendPoll.js
  54. 86
      src/views/components/SendPresence.js
  55. 64
      src/views/components/SendVideo.js
  56. 25
      src/views/components/generic/FormRecipient.js
  57. 102
      src/views/index.html

96
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.1.0
description: This API is used for sending whatsapp via API
servers:
- url: http://localhost:3000
@ -131,8 +131,9 @@ paths:
- name: phone
in: query
schema:
type: integer
type: string
example: '6289685028129@s.whatsapp.net'
description: Phone number with country code
responses:
'200':
description: OK
@ -162,13 +163,15 @@ paths:
- name: phone
in: query
schema:
type: integer
type: string
example: '6289685028129@s.whatsapp.net'
description: Phone number with country code
- name: is_preview
in: query
schema:
type: boolean
example: true
description: Whether to fetch a preview of the avatar
responses:
'200':
description: OK
@ -188,6 +191,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
@ -316,6 +353,10 @@ paths:
type: string
format: binary
description: Image to send
image_url:
type: string
example: https://example.com/image.jpg
description: Image URL to send
compress:
type: boolean
example: false
@ -656,6 +697,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
@ -1247,13 +1327,17 @@ components:
type: number
UserInfoResponse:
type: object
required:
- code
- message
- results
properties:
code:
type: string
example: SUCCESS
message:
type: string
example:
example: Success
results:
type: object
properties:
@ -1501,7 +1585,7 @@ components:
properties:
text:
type: string
example: "WhatsApps official channel. Follow for our latest feature launches, updates, exclusive drops and more."
example: "WhatsApp's official channel. Follow for our latest feature launches, updates, exclusive drops and more."
id:
type: string
example: "1689653839450668"

170
readme.md

@ -1,60 +1,64 @@
## 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
- Mention someone
- `@phoneNumber`
- example: `Hello @628974812XXXX, @628974812XXXX`
- Post Whatsapp Status
- 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 +66,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,22 +90,48 @@ 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"
```
### Production Mode (docker compose)
create `docker-compose.yml` file with the following configuration:
```yml
services:
whatsapp:
image: aldinokemal2104/go-whatsapp-web-multidevice
container_name: whatsapp
restart: always
ports:
- "3000:3000"
volumes:
- whatsapp:/app/storages
command:
- --basic-auth=admin:admin
- --port=3000
- --debug=true
- --os=Chrome
- --account-validation=false
volumes:
whatsapp:
```
### Production Mode (binary)
- download binary from [release](https://github.com/aldinokemal/go-whatsapp-web-multidevice/releases)
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)
- [API Specification Document](https://bump.sh/aldinokemal/doc/go-whatsapp-web-multidevice).
- Check [docs/openapi.yml](./docs/openapi.yaml) for detailed API specifications.
- Use [SwaggerEditor](https://editor.swagger.io) to visualize the API.
- Generate HTTP clients using [openapi-generator](https://openapi-generator.tech/#try).
## CURL for the api
@ -109,36 +139,37 @@ You can fork or edit this source code !
- curl -X 'GET' 'http://127.0.0.1:3000/user/check?phone=YOUR_PHONE' -H 'accept: application/json' \
-H 'Authorization: Basic qwertyASDFzxc='
- curl -X 'GET' 'http://127.0.0.1:3000/user/check?phone=YOUR_PHONE' -H 'accept: application/json'
| 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 |
| ✅ | Check User is on whatsapp | GET | /user/check |
| ✅ | 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 |
| ✅ | Edit Message | POST | /message/:message_id/update |
| ✅ | Read Message (DM) | POST | /message/:message_id/read |
| ❌ | Star message | POST | /message/:message_id/star |
| ❌ | Star Message | POST | /message/:message_id/star |
| ✅ | Join Group With Link | POST | /group/join-with-link |
| ✅ | Leave Group | POST | /group/leave |
| ✅ | Create Group | POST | /group |
@ -148,39 +179,42 @@ 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) |
| Check User | ![Check User ](https://i.ibb.co/92gVZrx/Check-User.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) |
| Check User | ![Check User ](https://i.ibb.co/92gVZrx/Check-User.png?v=1) |
| 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

20
src/cmd/root.go

@ -46,12 +46,13 @@ 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"`)
rootCmd.PersistentFlags().IntVarP(&config.AppChatFlushIntervalDays, "chat-flush-interval", "", config.AppChatFlushIntervalDays, `the interval to flush the chat storage --chat-flush-interval <number> | example: --chat-flush-interval=7`)
}
func runRest(_ *cobra.Command, _ []string) {
@ -74,12 +75,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 +98,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>")
@ -142,6 +149,9 @@ func runRest(_ *cobra.Command, _ []string) {
go helpers.SetAutoConnectAfterBooting(appService)
// Set auto reconnect checking
go helpers.SetAutoReconnectChecking(cli)
// Start auto flush chat csv
go helpers.StartAutoFlushChatStorage()
if err = app.Listen(":" + config.AppPort); err != nil {
log.Fatalln("Failed to start: ", err.Error())
}

25
src/config/settings.go

@ -5,24 +5,27 @@ import (
)
var (
AppVersion = "v4.22.1"
AppPort = "3000"
AppDebug = false
AppOs = "AldinoKemal"
AppPlatform = waCompanionReg.DeviceProps_PlatformType(1)
AppBasicAuthCredential string
AppVersion = "v5.1.0"
AppPort = "3000"
AppDebug = false
AppOs = "AldinoKemal"
AppPlatform = waCompanionReg.DeviceProps_PlatformType(1)
AppBasicAuthCredential []string
AppChatFlushIntervalDays = 7 // Number of days before flushing chat.csv
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.csv"
DBURI = "file:storages/whatsapp.db?_foreign_keys=off"
WhatsappAutoReplyMessage string
WhatsappWebhook string
WhatsappWebhook []string
WhatsappWebhookSecret = "secret"
WhatsappLogLevel = "ERROR"
WhatsappSettingMaxImageSize int64 = 20000000 // 20MB
WhatsappSettingMaxFileSize int64 = 50000000 // 50MB
WhatsappSettingMaxVideoSize int64 = 100000000 // 100MB
WhatsappSettingMaxDownloadSize int64 = 500000000 // 500MB

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

2
src/domains/send/audio.go

@ -4,5 +4,5 @@ import "mime/multipart"
type AudioRequest struct {
Phone string `json:"phone" form:"phone"`
Audio *multipart.FileHeader `json:"Audio" form:"Audio"`
Audio *multipart.FileHeader `json:"audio" form:"audio"`
}

1
src/domains/send/image.go

@ -6,6 +6,7 @@ type ImageRequest struct {
Phone string `json:"phone" form:"phone"`
Caption string `json:"caption" form:"caption"`
Image *multipart.FileHeader `json:"image" form:"image"`
ImageURL *string `json:"image_url" form:"image_url"`
ViewOnce bool `json:"view_once" form:"view_once"`
Compress bool `json:"compress"`
}

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"`
@ -71,3 +74,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

@ -8,6 +8,7 @@ type IUserService interface {
Info(ctx context.Context, request InfoRequest) (response InfoResponse, err error)
Check(ctx context.Context, request CheckRequest) (response CheckResponse, 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)

21
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
@ -21,8 +21,8 @@ require (
github.com/stretchr/testify v1.10.0
github.com/valyala/fasthttp v1.58.0
go.mau.fi/libsignal v0.1.1
go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19
google.golang.org/protobuf v1.36.1
go.mau.fi/whatsmeow v0.0.0-20250204095649-a75587ab11d7
google.golang.org/protobuf v1.36.4
)
require (
@ -38,22 +38,21 @@ 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
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/pflag v1.0.6 // 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
golang.org/x/image v0.23.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
go.mau.fi/util v0.8.4 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.30.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

33
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=
@ -80,6 +81,8 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -96,20 +99,27 @@ 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=
go.mau.fi/whatsmeow v0.0.0-20250130221717-faf72d668860 h1:jQhAJJGC42rwZ562nz6V9SXBCVz+QhORusd0r9cxiww=
go.mau.fi/whatsmeow v0.0.0-20250130221717-faf72d668860/go.mod h1:PG1x7fBW66I9q/e8a9mU2qF9M94+kK32MceMWgxBoiw=
go.mau.fi/whatsmeow v0.0.0-20250204095649-a75587ab11d7 h1:eLT0TKTpSeNcszoyasSylaxFCNU82XCP72DjbInFpzg=
go.mau.fi/whatsmeow v0.0.0-20250204095649-a75587ab11d7/go.mod h1:PG1x7fBW66I9q/e8a9mU2qF9M94+kK32MceMWgxBoiw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.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=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -123,8 +133,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=
@ -148,6 +159,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -173,8 +186,10 @@ 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=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/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=

46
src/internal/rest/helpers/flushChatCsv.go

@ -0,0 +1,46 @@
package helpers
import (
"os"
"sync"
"time"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
"github.com/sirupsen/logrus"
)
var flushMutex sync.Mutex
func FlushChatCsv() error {
flushMutex.Lock()
defer flushMutex.Unlock()
// Create an empty file (truncating any existing content)
file, err := os.OpenFile(config.PathChatStorage, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer file.Close()
return nil
}
// StartAutoFlushChatStorage starts a goroutine that periodically flushes the chat storage
func StartAutoFlushChatStorage() {
interval := time.Duration(config.AppChatFlushIntervalDays) * 24 * time.Hour
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if err := FlushChatCsv(); err != nil {
logrus.Errorf("Error flushing chat storage: %v", err)
} else {
logrus.Info("Successfully flushed chat storage")
}
}
}()
logrus.Infof("Auto flush for chat storage started (your account chat still safe). Will flush every %d days", config.AppChatFlushIntervalDays)
}

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

22
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
}
@ -51,9 +52,10 @@ func (controller *Send) SendImage(c *fiber.Ctx) error {
utils.PanicIfNeeded(err)
file, err := c.FormFile("image")
utils.PanicIfNeeded(err)
if err == nil {
request.Image = file
}
request.Image = file
whatsapp.SanitizePhone(&request.Phone)
response, err := controller.Service.SendImage(c.UserContext(), request)
@ -204,3 +206,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

@ -16,6 +16,7 @@ func InitRestUser(app *fiber.App, service domainUser.IUserService) User {
app.Get("/user/info", rest.UserInfo)
app.Get("/user/check", rest.UserCheck)
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)
@ -78,6 +79,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)

96
src/pkg/utils/chat_storage.go

@ -0,0 +1,96 @@
package utils
import (
"encoding/csv"
"fmt"
"os"
"sync"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
)
type RecordedMessage struct {
MessageID string `json:"message_id,omitempty"`
JID string `json:"jid,omitempty"`
MessageContent string `json:"message_content,omitempty"`
}
// mutex to prevent concurrent file access
var fileMutex sync.Mutex
func FindRecordFromStorage(messageID string) (RecordedMessage, error) {
fileMutex.Lock()
defer fileMutex.Unlock()
file, err := os.OpenFile(config.PathChatStorage, os.O_RDONLY|os.O_CREATE, 0644)
if err != nil {
return RecordedMessage{}, fmt.Errorf("failed to open storage file: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
records, err := reader.ReadAll()
if err != nil {
return RecordedMessage{}, fmt.Errorf("failed to read CSV records: %w", err)
}
for _, record := range records {
if len(record) == 3 && record[0] == messageID {
return RecordedMessage{
MessageID: record[0],
JID: record[1],
MessageContent: record[2],
}, nil
}
}
return RecordedMessage{}, fmt.Errorf("message ID %s not found in storage", messageID)
}
func RecordMessage(messageID string, senderJID string, messageContent string) error {
fileMutex.Lock()
defer fileMutex.Unlock()
message := RecordedMessage{
MessageID: messageID,
JID: senderJID,
MessageContent: messageContent,
}
// Read existing messages
var records [][]string
if file, err := os.OpenFile(config.PathChatStorage, os.O_RDONLY|os.O_CREATE, 0644); err == nil {
defer file.Close()
reader := csv.NewReader(file)
records, err = reader.ReadAll()
if err != nil {
return fmt.Errorf("failed to read existing records: %w", err)
}
// Check for duplicates
for _, record := range records {
if len(record) == 3 && record[0] == messageID {
return nil // Skip if duplicate found
}
}
}
// Prepare the new record
newRecord := []string{message.MessageID, message.JID, message.MessageContent}
records = append([][]string{newRecord}, records...) // Prepend new message
// Write all records back to file
file, err := os.OpenFile(config.PathChatStorage, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to open file for writing: %w", err)
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
if err := writer.WriteAll(records); err != nil {
return fmt.Errorf("failed to write CSV records: %w", err)
}
return nil
}

76
src/pkg/utils/general.go

@ -2,7 +2,7 @@ package utils
import (
"fmt"
"github.com/PuerkitoBio/goquery"
"io"
"log"
"net/http"
"os"
@ -11,6 +11,9 @@ import (
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
)
// RemoveFile is removing file with delay
@ -64,6 +67,8 @@ func StrToFloat64(text string) float64 {
type Metadata struct {
Title string
Description string
Image string
ImageThumb []byte
}
func GetMetaDataFromURL(url string) (meta Metadata) {
@ -89,8 +94,26 @@ func GetMetaDataFromURL(url string) (meta Metadata) {
meta.Title = element.Text()
})
// Print the meta description
fmt.Println("Meta data:", meta)
document.Find("meta[property='og:image']").Each(func(index int, element *goquery.Selection) {
meta.Image, _ = element.Attr("content")
})
// If an og:image is found, download it and store its content in ImageThumb
if meta.Image != "" {
imageResponse, err := http.Get(meta.Image)
if err != nil {
log.Printf("Failed to download image: %v", err)
} else {
defer imageResponse.Body.Close()
imageData, err := io.ReadAll(imageResponse.Body)
if err != nil {
log.Printf("Failed to read image data: %v", err)
} else {
meta.ImageThumb = imageData
}
}
}
return meta
}
@ -109,3 +132,50 @@ func ContainsMention(message string) []string {
}
return phoneNumbers
}
func DownloadImageFromURL(url string) ([]byte, string, error) {
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
response, err := client.Get(url)
if err != nil {
return nil, "", err
}
defer response.Body.Close()
contentType := response.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
return nil, "", fmt.Errorf("invalid content type: %s", contentType)
}
// Check content length if available
if contentLength := response.ContentLength; contentLength > int64(config.WhatsappSettingMaxImageSize) {
return nil, "", fmt.Errorf("image size %d exceeds maximum allowed size %d", contentLength, config.WhatsappSettingMaxImageSize)
}
// Limit the size from config
reader := io.LimitReader(response.Body, int64(config.WhatsappSettingMaxImageSize))
// Extract the file name from the URL and remove query parameters if present
segments := strings.Split(url, "/")
fileName := segments[len(segments)-1]
fileName = strings.Split(fileName, "?")[0]
// Check if the file extension is supported
allowedExtensions := map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
}
extension := strings.ToLower(filepath.Ext(fileName))
if !allowedExtensions[extension] {
return nil, "", fmt.Errorf("unsupported file type: %s", extension)
}
imageData, err := io.ReadAll(reader)
if err != nil {
return nil, "", err
}
return imageData, fileName, nil
}

88
src/pkg/utils/general_test.go

@ -1,12 +1,21 @@
package utils_test
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
"github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/suite"
)
func TestContainsMention(t *testing.T) {
type UtilsTestSuite struct {
suite.Suite
}
func (suite *UtilsTestSuite) TestContainsMention() {
type args struct {
message string
}
@ -32,9 +41,82 @@ func TestContainsMention(t *testing.T) {
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
suite.T().Run(tt.name, func(t *testing.T) {
got := utils.ContainsMention(tt.args.message)
assert.Equal(t, tt.want, got)
})
}
}
func (suite *UtilsTestSuite) TestRemoveFile() {
tempFile, err := os.CreateTemp("", "testfile")
assert.NoError(suite.T(), err)
tempFilePath := tempFile.Name()
tempFile.Close()
err = utils.RemoveFile(0, tempFilePath)
assert.NoError(suite.T(), err)
_, err = os.Stat(tempFilePath)
assert.True(suite.T(), os.IsNotExist(err))
}
func (suite *UtilsTestSuite) TestCreateFolder() {
tempDir := "testdir"
err := utils.CreateFolder(tempDir)
assert.NoError(suite.T(), err)
_, err = os.Stat(tempDir)
assert.NoError(suite.T(), err)
assert.True(suite.T(), err == nil)
os.RemoveAll(tempDir)
}
func (suite *UtilsTestSuite) TestPanicIfNeeded() {
assert.PanicsWithValue(suite.T(), "test error", func() {
utils.PanicIfNeeded("test error")
})
assert.NotPanics(suite.T(), func() {
utils.PanicIfNeeded(nil)
})
}
func (suite *UtilsTestSuite) TestStrToFloat64() {
assert.Equal(suite.T(), 123.45, utils.StrToFloat64("123.45"))
assert.Equal(suite.T(), 0.0, utils.StrToFloat64("invalid"))
assert.Equal(suite.T(), 0.0, utils.StrToFloat64(""))
}
func (suite *UtilsTestSuite) TestGetMetaDataFromURL() {
// Use httptest.NewServer to mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<!DOCTYPE html><html><head><title>Test Title</title><meta name='description' content='Test Description'><meta property='og:image' content='http://example.com/image.jpg'></head><body></body></html>`))
}))
defer server.Close() // Ensure the server is closed when the test ends
meta := utils.GetMetaDataFromURL(server.URL)
assert.Equal(suite.T(), "Test Title", meta.Title)
assert.Equal(suite.T(), "Test Description", meta.Description)
assert.Equal(suite.T(), "http://example.com/image.jpg", meta.Image)
}
func (suite *UtilsTestSuite) TestDownloadImageFromURL() {
// Use httptest.NewServer to mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/image.jpg" {
w.Header().Set("Content-Type", "image/jpeg") // Set content type to image
w.Write([]byte("image data"))
} else {
http.NotFound(w, r)
}
}))
defer server.Close() // Ensure the server is closed when the test ends
imageData, fileName, err := utils.DownloadImageFromURL(server.URL + "/image.jpg")
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), []byte("image data"), imageData)
assert.Equal(suite.T(), "image.jpg", fileName)
}
func TestUtilsTestSuite(t *testing.T) {
suite.Run(t, new(UtilsTestSuite))
}

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
}

158
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
}
@ -110,14 +119,31 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima
var (
imagePath string
imageThumbnail string
imageName string
deletedItems []string
oriImagePath string
)
// Save image to server
oriImagePath := fmt.Sprintf("%s/%s", config.PathSendItems, request.Image.Filename)
err = fasthttp.SaveMultipartFile(request.Image, oriImagePath)
if err != nil {
return response, err
if request.ImageURL != nil && *request.ImageURL != "" {
// Download image from URL
imageData, fileName, err := utils.DownloadImageFromURL(*request.ImageURL)
oriImagePath = fmt.Sprintf("%s/%s", config.PathSendItems, fileName)
if err != nil {
return response, pkgError.InternalServerError(fmt.Sprintf("failed to download image from URL %v", err))
}
imageName = fileName
err = os.WriteFile(oriImagePath, imageData, 0644)
if err != nil {
return response, pkgError.InternalServerError(fmt.Sprintf("failed to save downloaded image %v", err))
}
} else if request.Image != nil {
// Save image to server
oriImagePath = fmt.Sprintf("%s/%s", config.PathSendItems, request.Image.Filename)
err = fasthttp.SaveMultipartFile(request.Image, oriImagePath)
if err != nil {
return response, err
}
imageName = request.Image.Filename
}
deletedItems = append(deletedItems, oriImagePath)
@ -129,7 +155,7 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima
// Resize Thumbnail
resizedImage := imaging.Resize(srcImage, 100, 0, imaging.Lanczos)
imageThumbnail = fmt.Sprintf("%s/thumbnails-%s", config.PathSendItems, request.Image.Filename)
imageThumbnail = fmt.Sprintf("%s/thumbnails-%s", config.PathSendItems, imageName)
if err = imaging.Save(resizedImage, imageThumbnail); err != nil {
return response, pkgError.InternalServerError(fmt.Sprintf("failed to save thumbnail %v", err))
}
@ -142,7 +168,7 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima
return response, pkgError.InternalServerError(fmt.Sprintf("failed to open image %v", err))
}
newImage := imaging.Resize(openImageBuffer, 600, 0, imaging.Lanczos)
newImagePath := fmt.Sprintf("%s/new-%s", config.PathSendItems, request.Image.Filename)
newImagePath := fmt.Sprintf("%s/new-%s", config.PathSendItems, imageName)
if err = imaging.Save(newImage, newImagePath); err != nil {
return response, pkgError.InternalServerError(fmt.Sprintf("failed to save image %v", err))
}
@ -180,7 +206,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 +259,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 +371,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 +407,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
}
@ -391,13 +433,18 @@ func (service serviceSend) SendLink(ctx context.Context, request domainSend.Link
getMetaDataFromURL := utils.GetMetaDataFromURL(request.Link)
msg := &waE2E.Message{ExtendedTextMessage: &waE2E.ExtendedTextMessage{
Text: proto.String(fmt.Sprintf("%s\n%s", request.Caption, request.Link)),
Title: proto.String(getMetaDataFromURL.Title),
CanonicalURL: proto.String(request.Link),
MatchedText: proto.String(request.Link),
Description: proto.String(getMetaDataFromURL.Description),
Text: proto.String(fmt.Sprintf("%s\n%s", request.Caption, request.Link)),
Title: proto.String(getMetaDataFromURL.Title),
MatchedText: proto.String(request.Link),
Description: proto.String(getMetaDataFromURL.Description),
JPEGThumbnail: getMetaDataFromURL.ImageThumb,
}}
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 +472,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 +516,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 +538,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 +550,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 +575,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 {
@ -190,3 +194,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
}

75
src/validations/send_validation.go

@ -3,6 +3,8 @@ package validations
import (
"context"
"fmt"
"sort"
"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"
@ -26,21 +28,37 @@ func ValidateSendMessage(ctx context.Context, request domainSend.MessageRequest)
func ValidateSendImage(ctx context.Context, request domainSend.ImageRequest) error {
err := validation.ValidateStructWithContext(ctx, &request,
validation.Field(&request.Phone, validation.Required),
validation.Field(&request.Image, validation.Required),
)
if err != nil {
return pkgError.ValidationError(err.Error())
}
availableMimes := map[string]bool{
"image/jpeg": true,
"image/jpg": true,
"image/png": true,
if request.Image == nil && (request.ImageURL == nil || *request.ImageURL == "") {
return pkgError.ValidationError("either Image or ImageURL must be provided")
}
if !availableMimes[request.Image.Header.Get("Content-Type")] {
return pkgError.ValidationError("your image is not allowed. please use jpg/jpeg/png")
if request.Image != nil {
availableMimes := map[string]bool{
"image/jpeg": true,
"image/jpg": true,
"image/png": true,
}
if !availableMimes[request.Image.Header.Get("Content-Type")] {
return pkgError.ValidationError("your image is not allowed. please use jpg/jpeg/png")
}
}
if request.ImageURL != nil {
if *request.ImageURL == "" {
return pkgError.ValidationError("ImageURL cannot be empty")
}
err := validation.Validate(*request.ImageURL, is.URL)
if err != nil {
return pkgError.ValidationError("ImageURL must be a valid URL")
}
}
return nil
@ -145,18 +163,16 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err
}
availableMimes := map[string]bool{
"audio/aac": true,
"audio/amr": true,
"audio/flac": true,
"audio/m4a": true,
"audio/m4r": true,
"audio/mp3": true,
"audio/mpeg": true,
"audio/ogg": true,
"audio/aac": true,
"audio/amr": true,
"audio/flac": true,
"audio/m4a": true,
"audio/m4r": true,
"audio/mp3": true,
"audio/mpeg": true,
"audio/ogg": true,
"audio/wma": true,
"audio/x-ms-wma": true,
"audio/wav": true,
"audio/vnd.wav": true,
"audio/vnd.wave": true,
@ -165,7 +181,15 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err
"audio/x-wav": true,
}
availableMimesStr := ""
// Sort MIME types for consistent error message order
mimeKeys := make([]string, 0, len(availableMimes))
for k := range availableMimes {
mimeKeys = append(mimeKeys, k)
}
sort.Strings(mimeKeys)
for _, k := range mimeKeys {
availableMimesStr += k + ","
}
@ -177,11 +201,15 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err
}
func ValidateSendPoll(ctx context.Context, request domainSend.PollRequest) error {
// Validate options first to ensure it is not blank before validating MaxAnswer
if len(request.Options) == 0 {
return pkgError.ValidationError("options: cannot be blank.")
}
err := validation.ValidateStructWithContext(ctx, &request,
validation.Field(&request.Phone, validation.Required),
validation.Field(&request.Question, validation.Required),
validation.Field(&request.Options, validation.Required),
validation.Field(&request.Options, validation.Each(validation.Required)),
validation.Field(&request.MaxAnswer, validation.Required),
@ -203,5 +231,16 @@ 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
}

187
src/validations/send_validation_test.go

@ -2,12 +2,13 @@ package validations
import (
"context"
"mime/multipart"
"testing"
domainMessage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message"
domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send"
pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
"github.com/stretchr/testify/assert"
"mime/multipart"
"testing"
)
func TestValidateSendMessage(t *testing.T) {
@ -91,7 +92,7 @@ func TestValidateSendImage(t *testing.T) {
Phone: "1728937129312@s.whatsapp.net",
Image: nil,
}},
err: pkgError.ValidationError("image: cannot be blank."),
err: pkgError.ValidationError("either Image or ImageURL must be provided"),
},
{
name: "should error with invalid image type",
@ -528,3 +529,183 @@ func TestValidateSendLocation(t *testing.T) {
})
}
}
func TestValidateSendAudio(t *testing.T) {
audio := &multipart.FileHeader{
Filename: "sample-audio.mp3",
Size: 100,
Header: map[string][]string{"Content-Type": {"audio/mp3"}},
}
type args struct {
request domainSend.AudioRequest
}
tests := []struct {
name string
args args
err any
}{
{
name: "should success with normal condition",
args: args{request: domainSend.AudioRequest{
Phone: "1728937129312@s.whatsapp.net",
Audio: audio,
}},
err: nil,
},
{
name: "should error with empty phone",
args: args{request: domainSend.AudioRequest{
Phone: "",
Audio: audio,
}},
err: pkgError.ValidationError("phone: cannot be blank."),
},
{
name: "should error with empty audio",
args: args{request: domainSend.AudioRequest{
Phone: "1728937129312@s.whatsapp.net",
Audio: nil,
}},
err: pkgError.ValidationError("audio: cannot be blank."),
},
{
name: "should error with invalid audio type",
args: args{request: domainSend.AudioRequest{
Phone: "1728937129312@s.whatsapp.net",
Audio: &multipart.FileHeader{
Filename: "sample-audio.txt",
Size: 100,
Header: map[string][]string{"Content-Type": {"text/plain"}},
},
}},
err: pkgError.ValidationError("your audio type is not allowed. please use (audio/aac,audio/amr,audio/flac,audio/m4a,audio/m4r,audio/mp3,audio/mpeg,audio/ogg,audio/vnd.wav,audio/vnd.wave,audio/wav,audio/wave,audio/wma,audio/x-ms-wma,audio/x-pn-wav,audio/x-wav,)"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSendAudio(context.Background(), tt.args.request)
assert.Equal(t, tt.err, err)
})
}
}
func TestValidateSendPoll(t *testing.T) {
type args struct {
request domainSend.PollRequest
}
tests := []struct {
name string
args args
err any
}{
{
name: "should success with normal condition",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "What is your favorite color?",
Options: []string{"Red", "Blue", "Green"},
MaxAnswer: 1,
}},
err: nil,
},
{
name: "should error with empty phone",
args: args{request: domainSend.PollRequest{
Phone: "",
Question: "What is your favorite color?",
Options: []string{"Red", "Blue", "Green"},
MaxAnswer: 1,
}},
err: pkgError.ValidationError("phone: cannot be blank."),
},
{
name: "should error with empty question",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "",
Options: []string{"Red", "Blue", "Green"},
MaxAnswer: 1,
}},
err: pkgError.ValidationError("question: cannot be blank."),
},
{
name: "should error with empty options",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "What is your favorite color?",
Options: []string{},
MaxAnswer: 5,
}},
err: pkgError.ValidationError("options: cannot be blank."),
},
{
name: "should error with duplicate options",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "What is your favorite color?",
Options: []string{"Red", "Red", "Green"},
MaxAnswer: 1,
}},
err: pkgError.ValidationError("options should be unique"),
},
{
name: "should error with max answer greater than options",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "What is your favorite color?",
Options: []string{"Red", "Blue", "Green"},
MaxAnswer: 5,
}},
err: pkgError.ValidationError("max_answer: must be no greater than 3."),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSendPoll(context.Background(), tt.args.request)
assert.Equal(t, tt.err, err)
})
}
}
func TestValidateSendPresence(t *testing.T) {
type args struct {
request domainSend.PresenceRequest
}
tests := []struct {
name string
args args
err any
}{
{
name: "should success with available type",
args: args{request: domainSend.PresenceRequest{
Type: "available",
}},
err: nil,
},
{
name: "should success with unavailable type",
args: args{request: domainSend.PresenceRequest{
Type: "unavailable",
}},
err: nil,
},
{
name: "should error with invalid type",
args: args{request: domainSend.PresenceRequest{
Type: "invalid",
}},
err: pkgError.ValidationError("type: must be a valid value."),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSendPresence(context.Background(), tt.args.request)
assert.Equal(t, tt.err, err)
})
}
}

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>
`

80
src/views/components/SendImage.js

@ -13,13 +13,15 @@ export default {
caption: '',
type: window.TYPEUSER,
loading: false,
selected_file: null
selected_file: null,
image_url: null,
preview_url: null
}
},
computed: {
phone_id() {
return this.phone + this.type;
}
},
},
methods: {
openModal() {
@ -29,7 +31,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 && !this.image_url) {
return false;
}
return true;
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
@ -46,8 +66,16 @@ export default {
payload.append("view_once", this.view_once)
payload.append("compress", this.compress)
payload.append("caption", this.caption)
payload.append('image', $("#file_image")[0].files[0])
const fileInput = $("#file_image");
if (fileInput.length > 0 && fileInput[0].files.length > 0) {
const file = fileInput[0].files[0];
payload.append('image', file);
}
if (this.image_url) {
payload.append('image_url', this.image_url)
}
let response = await window.http.post(`/send/image`, payload)
this.handleReset();
return response.data.message;
@ -65,9 +93,25 @@ export default {
this.compress = false;
this.phone = '';
this.caption = '';
this.type = window.TYPEUSER;
this.preview_url = null;
this.selected_file = null;
this.image_url = 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,45 +132,55 @@ 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">
<label>Check for compressing image to smaller size</label>
</div>
</div>
<div class="field">
<label>Image URL</label>
<input type="text" v-model="image_url" placeholder="https://example.com/image.jpg"
aria-label="image_url"/>
</div>
<div style="text-align: left; font-weight: bold; margin: 10px 0;">or you can upload image from your device</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>
`

46
src/views/components/SendMessage.js

@ -17,7 +17,7 @@ export default {
computed: {
phone_id() {
return this.phone + this.type;
}
},
},
methods: {
openModal() {
@ -27,13 +27,29 @@ 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;
return isPhoneValid && isMessageValid
},
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 +57,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 +78,6 @@ export default {
handleReset() {
this.phone = '';
this.text = '';
this.type = window.TYPEUSER;
this.reply_message_id = '';
},
},
@ -85,8 +100,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 +115,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-user-check></account-user-check>
<account-privacy></account-privacy>
@ -110,6 +165,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',
@ -149,6 +205,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";
@ -158,6 +215,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 AccountUserCheck from "./components/AccountUserCheck.js";
import AccountPrivacy from "./components/AccountPrivacy.js";
@ -196,11 +254,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, AccountUserCheck, AccountPrivacy
AccountAvatar, AccountUserInfo, AccountUserCheck, AccountPrivacy, AccountChangeAvatar
},
delimiters: ['[[', ']]'],
data() {
@ -215,6 +273,9 @@
}
},
mounted() {
// Initialize app container as hidden
document.getElementById('app').style.display = 'none';
if (window["WebSocket"]) {
this.app_ws = new WebSocket(constructWebSocketURL());
@ -222,13 +283,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)
@ -249,6 +321,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