From b7e0ada66e70e3ee7311aa8d78cde6b9ca8f7867 Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Fri, 31 Jan 2025 07:11:31 +0700 Subject: [PATCH 1/6] feat: wa status, enhance reply, update ui, multiple webhook commit acb35b63b722d0df50c11fd922aec2ebc827f12f Author: Aldino Kemal Date: Fri Jan 31 07:10:27 2025 +0700 feat: change multiple basic auth commit 993b5188c5ac509f7ab8e12ca275ff5043f07e2c Author: Aldino Kemal Date: Fri Jan 31 07:06:57 2025 +0700 feat: support multiple webhook commit 1308039a166d5e508947c8fe39b23f1dcb98e7eb Author: Aldino Kemal Date: Tue Jan 21 19:41:15 2025 +0700 fix: login with code commit 1a55ccb3eb9b4a8e93ca93f2ec72151e586ea470 Author: Aldino Kemal Date: Tue Jan 21 15:12:45 2025 +0700 feat: add star message commit 6a4ef881150c1a0fa629dcbe3db41be429074f42 Author: Aldino Kemal Date: Tue Jan 21 14:16:03 2025 +0700 feat: add change avatar api commit 52c738d2ce26c76e712154fb185ecd73cf9c38c6 Author: Aldino Kemal Date: Tue Jan 21 12:07:13 2025 +0700 feat: add send presence commit 3e4cbf8cb238068bae18d5dd4c2a5cafd5070910 Author: Aldino Kemal Date: Tue Jan 21 10:52:05 2025 +0700 fix: reply message commit e08ae59992e8192d35b9ddb256e657de4097da1e Author: Aldino Kemal Date: Tue Jan 21 08:32:21 2025 +0700 fix: reply issue commit fa80cf35026646d329768ade92d0bde14c9e709f Author: Aldino Kemal Date: Tue Jan 21 05:34:55 2025 +0700 feat: optimize UI validation commit ac58b9579e1d643c03dd4be212d1ca7c81c5b61c Author: Aldino Kemal Date: Tue Jan 21 00:11:48 2025 +0700 refactor: move isValidForm methods in SendImage, SendMessage, and SendVideo refactor(SendImage.js): move isValidForm method to computed section refactor(SendMessage.js): move isValidForm method to computed section refactor(SendVideo.js): remove errors object and resetErrors call fix(SendVideo.js): change isFormValid to isValidForm and ensure consistency commit 1dc30bcd8662ff1070204dc94897a0144929ff7f Author: Aldino Kemal Date: Mon Jan 20 23:02:26 2025 +0700 feat: optimize UI commit e652eabb0aee90c038447bce9671827fd3fa4b3b Author: Aldino Kemal Date: Mon Jan 20 21:22:13 2025 +0700 feat: update package --- docs/openapi.yaml | 77 ++++- readme.md | 125 ++++---- src/cmd/root.go | 16 +- src/config/settings.go | 15 +- src/domains/message/message.go | 7 + src/domains/send/presence.go | 5 + src/domains/send/send.go | 1 + src/domains/user/account.go | 9 +- src/domains/user/user.go | 1 + src/go.mod | 13 +- src/go.sum | 21 +- src/internal/rest/message.go | 41 +++ src/internal/rest/send.go | 17 ++ src/internal/rest/user.go | 19 ++ src/pkg/utils/chat_storage.go | 98 ++++++ src/pkg/whatsapp/init.go | 5 +- src/pkg/whatsapp/utils.go | 111 +++++++ src/pkg/whatsapp/webhook.go | 10 +- src/services/app.go | 14 +- src/services/message.go | 27 +- src/services/send.go | 116 +++++-- src/services/user.go | 60 +++- src/validations/message_validation.go | 15 + src/validations/send_validation.go | 13 + src/views/assets/app.css | 282 ++++++++++++++++++ src/views/components/AccountAvatar.js | 16 +- src/views/components/AccountChangeAvatar.js | 113 +++++++ src/views/components/AccountPrivacy.js | 1 + src/views/components/AccountUserInfo.js | 16 +- src/views/components/AppLogin.js | 1 + src/views/components/AppLoginWithCode.js | 5 +- src/views/components/AppLogout.js | 1 + src/views/components/AppReconnect.js | 1 + src/views/components/GroupCreate.js | 20 +- src/views/components/GroupJoinWithLink.js | 23 +- .../components/GroupManageParticipants.js | 17 +- src/views/components/MessageDelete.js | 21 +- src/views/components/MessageReact.js | 18 +- src/views/components/MessageRevoke.js | 18 +- src/views/components/MessageUpdate.js | 18 +- src/views/components/SendAudio.js | 37 ++- src/views/components/SendContact.js | 25 +- src/views/components/SendFile.js | 37 ++- src/views/components/SendImage.js | 60 +++- src/views/components/SendLocation.js | 26 +- src/views/components/SendMessage.js | 50 +++- src/views/components/SendPoll.js | 25 +- src/views/components/SendPresence.js | 86 ++++++ src/views/components/SendVideo.js | 64 +++- src/views/components/generic/FormRecipient.js | 25 +- src/views/index.html | 102 ++++++- 51 files changed, 1711 insertions(+), 233 deletions(-) create mode 100644 src/domains/send/presence.go create mode 100644 src/pkg/utils/chat_storage.go create mode 100644 src/views/assets/app.css create mode 100644 src/views/components/AccountChangeAvatar.js create mode 100644 src/views/components/SendPresence.js diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c278281..d274d61 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ -openapi: 3.0.0 +openapi: "3.0.0" info: title: WhatsApp API MultiDevice - version: 4.4.0 + version: 5.0.0 description: This API is used for sending whatsapp via API servers: - url: http://localhost:3000 @@ -188,6 +188,40 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorInternalServer' + post: + operationId: userChangeAvatar + tags: + - user + summary: User Change Avatar + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + avatar: + type: string + format: binary + description: Avatar to send + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBadRequest' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorInternalServer' /user/my/privacy: get: operationId: userMyPrivacy @@ -656,6 +690,45 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorInternalServer' + /send/presence: + post: + operationId: sendPresence + tags: + - send + summary: Send presence status + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + presence: + type: string + description: The presence status to send + enum: [available, unavailable] + example: 'available' + required: + - presence + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SendResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBadRequest' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorInternalServer' /message/{message_id}/revoke: post: operationId: revokeMessage diff --git a/readme.md b/readme.md index 4052b5c..b8faa0c 100644 --- a/readme.md +++ b/readme.md @@ -1,60 +1,60 @@ -## WhatsApp API Multi Device Version +# WhatsApp API Multi Device Version ![release version](https://img.shields.io/github/v/release/aldinokemal/go-whatsapp-web-multidevice) -
+ ![Build Image](https://github.com/aldinokemal/go-whatsapp-web-multidevice/actions/workflows/build-docker-image.yaml/badge.svg) -
+ ![release windows](https://github.com/aldinokemal/go-whatsapp-web-multidevice/actions/workflows/release-windows.yml/badge.svg) ![release linux](https://github.com/aldinokemal/go-whatsapp-web-multidevice/actions/workflows/release-linux.yml/badge.svg) ![release macos](https://github.com/aldinokemal/go-whatsapp-web-multidevice/actions/workflows/release-mac.yml/badge.svg) -### Support `ARM` Architecture +## Support `ARM` Architecture Now that we support ARM64 for Linux: - [Release](https://github.com/aldinokemal/go-whatsapp-web-multidevice/releases/latest) for ARM64 - [Docker Image](https://hub.docker.com/r/aldinokemal2104/go-whatsapp-web-multidevice/tags) for ARM64. -### Feature +## Feature - Send WhatsApp message via http API, [docs/openapi.yml](./docs/openapi.yaml) for more details - Compress image before send - Compress video before send - Change OS name become your app (it's the device name when connect via mobile) - - `--os=Chrome` or `--os=MyApplication` + - `--os=Chrome` or `--os=MyApplication` - Basic Auth (able to add multi credentials) - - `--basic-auth=kemal:secret,toni:password,userName:secretPassword`, or you can simplify - - `-b=kemal:secret,toni:password,userName:secretPassword` + - `--basic-auth=kemal:secret,toni:password,userName:secretPassword`, or you can simplify + - `-b=kemal:secret,toni:password,userName:secretPassword` - Customizable port and debug mode - - `--port 8000` - - `--debug true` + - `--port 8000` + - `--debug true` - Auto reply message - - `--autoreply="Don't reply this message"` + - `--autoreply="Don't reply this message"` - Webhook for received message - - `--webhook="http://yourwebhook.site/handler"`, or you can simplify - - `-w="http://yourwebhook.site/handler"` + - `--webhook="http://yourwebhook.site/handler"`, or you can simplify + - `-w="http://yourwebhook.site/handler"` - Webhook Secret Our webhook will be sent to you with an HMAC header and a sha256 default key `secret`.
You may modify this by using the option below: - - `--webhook-secret="secret"` + - `--webhook-secret="secret"` - For more command `./main --help` -### Required (without docker) +## Required (without docker) - Mac OS: - - `brew install ffmpeg` - - `export CGO_CFLAGS_ALLOW="-Xpreprocessor"` + - `brew install ffmpeg` + - `export CGO_CFLAGS_ALLOW="-Xpreprocessor"` - Linux: - - `sudo apt update` - - `sudo apt install ffmpeg` + - `sudo apt update` + - `sudo apt install ffmpeg` - Windows (not recomended, prefer using [WSL](https://docs.microsoft.com/en-us/windows/wsl/install)): - - install ffmpeg, download [here](https://www.ffmpeg.org/download.html#build-windows) - - add to ffmpeg to [environment variable](https://www.google.com/search?q=windows+add+to+environment+path) + - install ffmpeg, download [here](https://www.ffmpeg.org/download.html#build-windows) + - add to ffmpeg to [environment variable](https://www.google.com/search?q=windows+add+to+environment+path) -### How to use +## How to use -#### Basic +### Basic 1. Clone this repo: `git clone https://github.com/aldinokemal/go-whatsapp-web-multidevice` 2. Open the folder that was cloned via cmd/terminal. @@ -62,14 +62,14 @@ Now that we support ARM64 for Linux: 4. run `go run main.go` 5. Open `http://localhost:3000` -#### Docker (you don't need to install in required) +### Docker (you don't need to install in required) 1. Clone this repo: `git clone https://github.com/aldinokemal/go-whatsapp-web-multidevice` 2. Open the folder that was cloned via cmd/terminal. 3. run `docker-compose up -d --build` 4. open `http://localhost:3000` -#### Build your own binary +### Build your own binary 1. Clone this repo `git clone https://github.com/aldinokemal/go-whatsapp-web-multidevice` 2. Open the folder that was cloned via cmd/terminal. @@ -86,7 +86,7 @@ Now that we support ARM64 for Linux: ### Production Mode (docker) -``` +```bash docker run --detach --publish=3000:3000 --name=whatsapp --restart=always --volume=$(docker volume create --name=whatsapp):/app/storages aldinokemal2104/go-whatsapp-web-multidevice --autoreply="Dont't reply this message please" ``` @@ -96,34 +96,36 @@ docker run --detach --publish=3000:3000 --name=whatsapp --restart=always --volum You can fork or edit this source code ! -### Current API +## Current API - [Api Specification Document](https://bump.sh/aldinokemal/doc/go-whatsapp-web-multidevice) - You can check [docs/openapi.yml](./docs/openapi.yaml) for detail API or paste to [SwaggerEditor](https://editor.swagger.io). - Furthermore you can generate HTTP Client from this API using [openapi-generator](https://openapi-generator.tech/#try) -| Feature | Menu | Method | URL | +| Feature | Menu | Method | URL | |---------|------------------------------|--------|-------------------------------| | ✅ | Login with Scan QR | GET | /app/login | | ✅ | Login With Pair Code | GET | /app/login-with-code | | ✅ | Logout | GET | /app/logout | -| ✅ | Reconnect | GET | /app/reconnect | -| ✅ | Devices | GET | /app/devices | +| ✅ | Reconnect | GET | /app/reconnect | +| ✅ | Devices | GET | /app/devices | | ✅ | User Info | GET | /user/info | | ✅ | User Avatar | GET | /user/avatar | +| ✅ | User Change Avatar | POST | /user/avatar | | ✅ | User My Groups | GET | /user/my/groups | | ✅ | User My Newsletter | GET | /user/my/newsletters | | ✅ | User My Privacy Setting | GET | /user/my/privacy | | ✅ | Send Message | POST | /send/message | -| ✅ | Send Image | POST | /send/image | -| ✅ | Send Audio | POST | /send/audio | -| ✅ | Send File | POST | /send/file | -| ✅ | Send Video | POST | /send/video | +| ✅ | Send Image | POST | /send/image | +| ✅ | Send Audio | POST | /send/audio | +| ✅ | Send File | POST | /send/file | +| ✅ | Send Video | POST | /send/video | | ✅ | Send Contact | POST | /send/contact | | ✅ | Send Link | POST | /send/link | | ✅ | Send Location | POST | /send/location | | ✅ | Send Poll / Vote | POST | /send/poll | +| ✅ | Send Presence | POST | /send/presence | | ✅ | Revoke Message | POST | /message/:message_id/revoke | | ✅ | React Message | POST | /message/:message_id/reaction | | ✅ | Delete Message | POST | /message/:message_id/delete | @@ -139,38 +141,41 @@ You can fork or edit this source code ! | ✅ | Demote Participant in Group | POST | /group/participants/demote | | ✅ | Unfollow Newsletter | POST | /newsletter/unfollow | -``` +```txt ✅ = Available ❌ = Not Available Yet ``` ### User Interface -| Description | Image | -|--------------------|------------------------------------------------------------------------------------------| -| Homepage | ![Homepage](https://i.ibb.co.com/Sy0dHZp/homepage-v4-20.png) | -| Login | ![Login](https://i.ibb.co.com/jkcB15R/login.png?v=1) | -| Login With Code | ![Login With Code](https://i.ibb.co.com/rdJGvGw/paircode.png) | -| Send Message | ![Send Message](https://i.ibb.co.com/rc3NXMX/send-message.png?v1) | -| Send Image | ![Send Image](https://i.ibb.co.com/BcFL3SD/send-image.png?v1) | -| Send File | ![Send File](https://i.ibb.co.com/f4yxjpp/send-file.png) | -| Send Video | ![Send Video](https://i.ibb.co.com/PrD3P51/send-video.png) | -| Send Contact | ![Send Contact](https://i.ibb.co.com/4810H7N/send-contact.png) | -| Send Location | ![Send Location](https://i.ibb.co.com/TWsy09G/send-location.png) | -| Send Audio | ![Send Audio](https://i.ibb.co.com/p1wL4wh/Send-Audio.png) | -| Send Poll | ![Send Poll](https://i.ibb.co.com/mq2fGHz/send-poll.png) | -| Revoke Message | ![Revoke Message](https://i.ibb.co.com/yswhvQY/revoke.png?v1) | -| Delete Message | ![Delete Message](https://i.ibb.co.com/F70SZ84/image.png) | -| Reaction Message | ![Reaction Message](https://i.ibb.co.com/BfHgSHG/react-message.png) | -| Edit Message | ![Edit Message](https://i.ibb.co.com/kXfpqJw/update-message.png) | -| User Info | ![User Info](https://i.ibb.co.com/3zjX6Cz/user-info.png?v=1) | -| User Avatar | ![User Avatar](https://i.ibb.co.com/ZmJZ4ZW/search-avatar.png?v=1) | -| My Privacy | ![My Privacy](https://i.ibb.co.com/Cw1sMQz/my-privacy.png) | -| My Group | ![My Group](https://i.ibb.co.com/WB268Xy/list-group.png) | -| Auto Reply | ![Auto Reply](https://i.ibb.co.com/D4rTytX/IMG-20220517-162500.jpg) | -| Basic Auth Prompt | ![Basic Auth Prompt](https://i.ibb.co.com/PDjQ92W/Screenshot-2022-11-06-at-14-06-29.png) | -| Manage Participant | ![Manage Participant](https://i.ibb.co.com/ynrN7cr/manage-participant.png) | -| My Newsletter | ![List Newsletter](https://i.ibb.co.com/WDg50jJ/image.png) | +| Description | Image | +|----------------------|------------------------------------------------------------------------------------------| +| Homepage | ![Homepage](https://i.ibb.co/251sHyF/Homepage.png) | +| Login | ![Login](https://i.ibb.co.com/xJyCWv8/login.png) | +| Login With Code | ![Login With Code](https://i.ibb.co.com/YDjyXby/login-With-Code.png) | +| Send Message | ![Send Message](https://i.ibb.co.com/7Y0wJ5R/send-Message.png) | +| Send Image | ![Send Image](https://i.ibb.co.com/NtyLLdS/send-Image.png) | +| Send File | ![Send File](https://i.ibb.co.com/D94yvnX/sendFile.png) | +| Send Video | ![Send Video](https://i.ibb.co.com/r0LdRFH/send-Video.png) | +| Send Contact | ![Send Contact](https://i.ibb.co.com/NsFfQBv/send-Contact.png) | +| Send Location | ![Send Location](https://i.ibb.co.com/vDGmFvk/send-Location.png) | +| Send Audio | ![Send Audio](https://i.ibb.co.com/XJdQLP8/send-Audio.png) | +| Send Poll | ![Send Poll](https://i.ibb.co.com/4TswfT3/sendPoll.png) | +| Send Presence | ![Send Presence](https://i.ibb.co.com/NSTC3QX/send-Presence.png) | +| Revoke Message | ![Revoke Message](https://i.ibb.co.com/r4nDc57/revoke-Message.png) | +| Delete Message | ![Delete Message](https://i.ibb.co.com/dtrTJ1M/delete-Message.png) | +| Reaction Message | ![Reaction Message](https://i.ibb.co.com/fNqJXF0/react-Message.png) | +| Edit Message | ![Edit Message](https://i.ibb.co.com/Vx5cQMg/update-Message.png) | +| User Info | ![User Info](https://i.ibb.co.com/qd7J7Nd/Search-User-Info.png) | +| User Avatar | ![User Avatar](https://i.ibb.co.com/zbSzpP3/Search-Avatar.png) | +| My Privacy | ![My Privacy](https://i.ibb.co.com/HCLxGHr/My-Privacy.png) | +| My Group | ![My Group](https://i.ibb.co.com/p19dFQ5/list-Group.png) | +| Create Group | ![My Group](https://i.ibb.co.com/YLT5Hyh/create-Group.png) | +| Join Group with LInk | ![My Group](https://i.ibb.co.com/x5p95J7/join-Group-With-Link.png) | +| Auto Reply | ![Auto Reply](https://i.ibb.co.com/D4rTytX/IMG-20220517-162500.jpg) | +| Basic Auth Prompt | ![Basic Auth Prompt](https://i.ibb.co.com/PDjQ92W/Screenshot-2022-11-06-at-14-06-29.png) | +| Manage Participant | ![Manage Participant](https://i.ibb.co.com/x7yn9nY/Manage-Participant.png) | +| My Newsletter | ![List Newsletter](https://i.ibb.co.com/BLvyS9j/My-Newsletter.png) | ### Mac OS NOTE diff --git a/src/cmd/root.go b/src/cmd/root.go index 7e362f0..23215ab 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -46,9 +46,9 @@ func init() { rootCmd.PersistentFlags().StringVarP(&config.AppPort, "port", "p", config.AppPort, "change port number with --port | example: --port=8080") rootCmd.PersistentFlags().BoolVarP(&config.AppDebug, "debug", "d", config.AppDebug, "hide or displaying log with --debug | example: --debug=true") rootCmd.PersistentFlags().StringVarP(&config.AppOs, "os", "", config.AppOs, `os name --os | 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 | example: --autoreply="Don't reply this message"`) - rootCmd.PersistentFlags().StringVarP(&config.WhatsappWebhook, "webhook", "w", config.WhatsappWebhook, `forward event to webhook --webhook | example: --webhook="https://yourcallback.com/callback"`) + rootCmd.PersistentFlags().StringSliceVarP(&config.WhatsappWebhook, "webhook", "w", config.WhatsappWebhook, `forward event to webhook --webhook | example: --webhook="https://yourcallback.com/callback"`) rootCmd.PersistentFlags().StringVarP(&config.WhatsappWebhookSecret, "webhook-secret", "", config.WhatsappWebhookSecret, `secure webhook request --webhook-secret | example: --webhook-secret="super-secret-key"`) rootCmd.PersistentFlags().BoolVarP(&config.WhatsappAccountValidation, "account-validation", "", config.WhatsappAccountValidation, `enable or disable account validation --account-validation | 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 | example: --db-uri="file:storages/whatsapp.db?_foreign_keys=off or postgres://user:password@localhost:5432/whatsapp"`) @@ -74,12 +74,19 @@ func runRest(_ *cobra.Command, _ []string) { Views: engine, BodyLimit: int(config.WhatsappSettingMaxVideoSize), }) + app.Static("/statics", "./statics") app.Use("/components", filesystem.New(filesystem.Config{ Root: http.FS(EmbedViews), PathPrefix: "views/components", Browse: true, })) + app.Use("/assets", filesystem.New(filesystem.Config{ + Root: http.FS(EmbedViews), + PathPrefix: "views/assets", + Browse: true, + })) + app.Use(middleware.Recovery()) app.Use(middleware.BasicAuth()) if config.AppDebug { @@ -90,10 +97,9 @@ func runRest(_ *cobra.Command, _ []string) { AllowHeaders: "Origin, Content-Type, Accept", })) - if config.AppBasicAuthCredential != "" { + if len(config.AppBasicAuthCredential) > 0 { account := make(map[string]string) - multipleBA := strings.Split(config.AppBasicAuthCredential, ",") - for _, basicAuth := range multipleBA { + for _, basicAuth := range config.AppBasicAuthCredential { ba := strings.Split(basicAuth, ":") if len(ba) != 2 { log.Fatalln("Basic auth is not valid, please this following format :") diff --git a/src/config/settings.go b/src/config/settings.go index ed1c350..3e2e64e 100644 --- a/src/config/settings.go +++ b/src/config/settings.go @@ -5,22 +5,23 @@ import ( ) var ( - AppVersion = "v4.22.1" + AppVersion = "v5.0.0" AppPort = "3000" AppDebug = false AppOs = "AldinoKemal" AppPlatform = waCompanionReg.DeviceProps_PlatformType(1) - AppBasicAuthCredential string + AppBasicAuthCredential []string - PathQrCode = "statics/qrcode" - PathSendItems = "statics/senditems" - PathMedia = "statics/media" - PathStorages = "storages" + PathQrCode = "statics/qrcode" + PathSendItems = "statics/senditems" + PathMedia = "statics/media" + PathStorages = "storages" + PathChatStorage = "storages/chat.txt" DBURI = "file:storages/whatsapp.db?_foreign_keys=off" WhatsappAutoReplyMessage string - WhatsappWebhook string + WhatsappWebhook []string WhatsappWebhookSecret = "secret" WhatsappLogLevel = "ERROR" WhatsappSettingMaxFileSize int64 = 50000000 // 50MB diff --git a/src/domains/message/message.go b/src/domains/message/message.go index 0de494b..a2db0ad 100644 --- a/src/domains/message/message.go +++ b/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"` +} diff --git a/src/domains/send/presence.go b/src/domains/send/presence.go new file mode 100644 index 0000000..301e3e3 --- /dev/null +++ b/src/domains/send/presence.go @@ -0,0 +1,5 @@ +package send + +type PresenceRequest struct { + Type string `json:"type" form:"type"` +} diff --git a/src/domains/send/send.go b/src/domains/send/send.go index 0e270c2..9e0be5e 100644 --- a/src/domains/send/send.go +++ b/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 { diff --git a/src/domains/user/account.go b/src/domains/user/account.go index d424755..039c373 100644 --- a/src/domains/user/account.go +++ b/src/domains/user/account.go @@ -1,6 +1,9 @@ package user -import "go.mau.fi/whatsmeow/types" +import ( + "go.mau.fi/whatsmeow/types" + "mime/multipart" +) type InfoRequest struct { Phone string `json:"phone" query:"phone"` @@ -52,3 +55,7 @@ type MyListGroupsResponse struct { type MyListNewsletterResponse struct { Data []types.NewsletterMetadata `json:"data"` } + +type ChangeAvatarRequest struct { + Avatar *multipart.FileHeader `json:"avatar" form:"avatar"` +} diff --git a/src/domains/user/user.go b/src/domains/user/user.go index feb3b78..7d47959 100644 --- a/src/domains/user/user.go +++ b/src/domains/user/user.go @@ -7,6 +7,7 @@ import ( type IUserService interface { Info(ctx context.Context, request InfoRequest) (response InfoResponse, err error) Avatar(ctx context.Context, request AvatarRequest) (response AvatarResponse, err error) + ChangeAvatar(ctx context.Context, request ChangeAvatarRequest) (err error) MyListGroups(ctx context.Context) (response MyListGroupsResponse, err error) MyListNewsletter(ctx context.Context) (response MyListNewsletterResponse, err error) MyPrivacySetting(ctx context.Context) (response MyPrivacySettingResponse, err error) diff --git a/src/go.mod b/src/go.mod index 4a8ecf2..4ed3390 100644 --- a/src/go.mod +++ b/src/go.mod @@ -10,7 +10,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/gofiber/fiber/v2 v2.52.6 - github.com/gofiber/template/html/v2 v2.1.2 + github.com/gofiber/template/html/v2 v2.1.3 github.com/gofiber/websocket/v2 v2.2.1 github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 @@ -22,7 +22,7 @@ require ( github.com/valyala/fasthttp v1.58.0 go.mau.fi/libsignal v0.1.1 go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19 - google.golang.org/protobuf v1.36.1 + google.golang.org/protobuf v1.36.3 ) require ( @@ -38,7 +38,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/kr/pretty v0.1.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -48,12 +48,11 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - go.mau.fi/util v0.8.3 // indirect - golang.org/x/crypto v0.31.0 // indirect + go.mau.fi/util v0.8.4 // indirect + golang.org/x/crypto v0.32.0 // indirect golang.org/x/image v0.23.0 // indirect - golang.org/x/net v0.33.0 // indirect + golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect - golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/src/go.sum b/src/go.sum index 682e7d0..e4ad709 100644 --- a/src/go.sum +++ b/src/go.sum @@ -27,8 +27,8 @@ github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27X github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= -github.com/gofiber/template/html/v2 v2.1.2 h1:wkK/mYJ3nIhongTkG3t0QgV4ADdgOYJYVSAF2AHnh8Y= -github.com/gofiber/template/html/v2 v2.1.2/go.mod h1:E98Z/FzvpaSib06aWEgYk6GXNf3ctoyaJH8yW5ay5ak= +github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o= +github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE= github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= @@ -50,8 +50,9 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -96,8 +97,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mau.fi/libsignal v0.1.1 h1:m/0PGBh4QKP/I1MQ44ti4C0fMbLMuHb95cmDw01FIpI= go.mau.fi/libsignal v0.1.1/go.mod h1:QLs89F/OA3ThdSL2Wz2p+o+fi8uuQUz0e1BRa6ExdBw= -go.mau.fi/util v0.8.3 h1:sulhXtfquMrQjsOP67x9CzWVBYUwhYeoo8hNQIpCWZ4= -go.mau.fi/util v0.8.3/go.mod h1:c00Db8xog70JeIsEvhdHooylTkTkakgnAOsZ04hplQY= +go.mau.fi/util v0.8.4 h1:mVKlJcXWfVo8ZW3f4vqtjGpqtZqJvX4ETekxawt2vnQ= +go.mau.fi/util v0.8.4/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY= go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19 h1:uVS+Zct5fF8rSXV9lfs87zoXdge0JXTzVGNkjmZ61UU= go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19/go.mod h1:TLzm2XkwgufONEmiVAsFny+9uBqyEZnUoPrQAfMyuSU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -105,8 +106,9 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= @@ -123,8 +125,9 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -173,8 +176,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/src/internal/rest/message.go b/src/internal/rest/message.go index 22086b5..8ec7ad1 100644 --- a/src/internal/rest/message.go +++ b/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, + }) +} diff --git a/src/internal/rest/send.go b/src/internal/rest/send.go index e0eb827..637c32b 100644 --- a/src/internal/rest/send.go +++ b/src/internal/rest/send.go @@ -22,6 +22,7 @@ func InitRestSend(app *fiber.App, service domainSend.ISendService) Send { app.Post("/send/location", rest.SendLocation) app.Post("/send/audio", rest.SendAudio) app.Post("/send/poll", rest.SendPoll) + app.Post("/send/presence", rest.SendPresence) return rest } @@ -204,3 +205,19 @@ func (controller *Send) SendPoll(c *fiber.Ctx) error { Results: response, }) } + +func (controller *Send) SendPresence(c *fiber.Ctx) error { + var request domainSend.PresenceRequest + err := c.BodyParser(&request) + utils.PanicIfNeeded(err) + + response, err := controller.Service.SendPresence(c.UserContext(), request) + utils.PanicIfNeeded(err) + + return c.JSON(utils.ResponseData{ + Status: 200, + Code: "SUCCESS", + Message: response.Status, + Results: response, + }) +} diff --git a/src/internal/rest/user.go b/src/internal/rest/user.go index 6621bb2..c357d6f 100644 --- a/src/internal/rest/user.go +++ b/src/internal/rest/user.go @@ -15,6 +15,7 @@ func InitRestUser(app *fiber.App, service domainUser.IUserService) User { rest := User{Service: service} app.Get("/user/info", rest.UserInfo) app.Get("/user/avatar", rest.UserAvatar) + app.Post("/user/avatar", rest.UserChangeAvatar) app.Get("/user/my/privacy", rest.UserMyPrivacySetting) app.Get("/user/my/groups", rest.UserMyListGroups) app.Get("/user/my/newsletters", rest.UserMyListNewsletter) @@ -58,6 +59,24 @@ func (controller *User) UserAvatar(c *fiber.Ctx) error { }) } +func (controller *User) UserChangeAvatar(c *fiber.Ctx) error { + var request domainUser.ChangeAvatarRequest + err := c.BodyParser(&request) + utils.PanicIfNeeded(err) + + request.Avatar, err = c.FormFile("avatar") + utils.PanicIfNeeded(err) + + err = controller.Service.ChangeAvatar(c.UserContext(), request) + utils.PanicIfNeeded(err) + + return c.JSON(utils.ResponseData{ + Status: 200, + Code: "SUCCESS", + Message: "Success change avatar", + }) +} + func (controller *User) UserMyPrivacySetting(c *fiber.Ctx) error { response, err := controller.Service.MyPrivacySetting(c.UserContext()) utils.PanicIfNeeded(err) diff --git a/src/pkg/utils/chat_storage.go b/src/pkg/utils/chat_storage.go new file mode 100644 index 0000000..9d8ea89 --- /dev/null +++ b/src/pkg/utils/chat_storage.go @@ -0,0 +1,98 @@ +package utils + +import ( + "fmt" + "os" + "strings" + + "github.com/aldinokemal/go-whatsapp-web-multidevice/config" + "github.com/gofiber/fiber/v2/log" +) + +type RecordedMessage struct { + MessageID string `json:"message_id,omitempty"` + JID string `json:"jid,omitempty"` + MessageContent string `json:"message_content,omitempty"` +} + +func FindRecordFromStorage(messageID string) (RecordedMessage, error) { + data, err := os.ReadFile(config.PathChatStorage) + if err != nil { + return RecordedMessage{}, err + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if line == "" { + continue + } + parts := strings.Split(line, ",") + if len(parts) == 3 && parts[0] == messageID { + return RecordedMessage{ + MessageID: parts[0], + JID: parts[1], + MessageContent: parts[2], + }, nil + } + } + return RecordedMessage{}, fmt.Errorf("message ID %s not found in storage", messageID) +} + +func RecordMessage(messageID string, senderJID string, messageContent string) { + message := RecordedMessage{ + MessageID: messageID, + JID: senderJID, + MessageContent: messageContent, + } + + // Read existing messages + var messages []RecordedMessage + if data, err := os.ReadFile(config.PathChatStorage); err == nil { + // Split file by newlines and parse each line + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if line == "" { + continue + } + parts := strings.Split(line, ",") + + msg := RecordedMessage{ + MessageID: parts[0], + JID: parts[1], + MessageContent: parts[2], + } + messages = append(messages, msg) + } + } + + // Check for duplicates + for _, msg := range messages { + if msg.MessageID == message.MessageID { + return // Skip if duplicate found + } + } + + // Write new message at the top + f, err := os.OpenFile(config.PathChatStorage, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + log.Errorf("Failed to open received-chat.txt: %v", err) + return + } + defer f.Close() + + // Write new message first + csvLine := fmt.Sprintf("%s,%s,%s\n", message.MessageID, message.JID, message.MessageContent) + if _, err := f.WriteString(csvLine); err != nil { + log.Errorf("Failed to write to received-chat.txt: %v", err) + return + } + + // Write existing messages after + for _, msg := range messages { + csvLine := fmt.Sprintf("%s,%s,%s\n", msg.MessageID, msg.JID, msg.MessageContent) + if _, err := f.WriteString(csvLine); err != nil { + log.Errorf("Failed to write to received-chat.txt: %v", err) + return + } + } +} diff --git a/src/pkg/whatsapp/init.go b/src/pkg/whatsapp/init.go index 65afe25..c81835a 100644 --- a/src/pkg/whatsapp/init.go +++ b/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) { diff --git a/src/pkg/whatsapp/utils.go b/src/pkg/whatsapp/utils.go index f737009..5f3b9a6 100644 --- a/src/pkg/whatsapp/utils.go +++ b/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 +} diff --git a/src/pkg/whatsapp/webhook.go b/src/pkg/whatsapp/webhook.go index 104d177..307b79d 100644 --- a/src/pkg/whatsapp/webhook.go +++ b/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)) } diff --git a/src/services/app.go b/src/services/app.go index 8aca9b9..a52b66a 100644 --- a/src/services/app.go +++ b/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()) diff --git a/src/services/message.go b/src/services/message.go index 2e40aa0..1a9eaaa 100644 --- a/src/services/message.go +++ b/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 +} diff --git a/src/services/send.go b/src/services/send.go index f465d0e..a003bcc 100644 --- a/src/services/send.go +++ b/src/services/send.go @@ -3,6 +3,10 @@ package services import ( "context" "fmt" + "net/http" + "os" + "os/exec" + "github.com/aldinokemal/go-whatsapp-web-multidevice/config" "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/app" domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send" @@ -19,9 +23,6 @@ import ( "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/types" "google.golang.org/protobuf/proto" - "net/http" - "os" - "os/exec" ) type serviceSend struct { @@ -36,6 +37,18 @@ func NewSendService(waCli *whatsmeow.Client, appService app.IAppService) domainS } } +// wrapSendMessage wraps the message sending process with message ID saving +func (service serviceSend) wrapSendMessage(ctx context.Context, recipient types.JID, msg *waE2E.Message, content string) (whatsmeow.SendResponse, error) { + ts, err := service.WaCli.SendMessage(ctx, recipient, msg) + if err != nil { + return whatsmeow.SendResponse{}, err + } + + utils.RecordMessage(ts.ID, service.WaCli.Store.ID.String(), content) + + return ts, nil +} + func (service serviceSend) SendText(ctx context.Context, request domainSend.MessageRequest) (response domainSend.GenericResponse, err error) { err = validations.ValidateSendMessage(ctx, request) if err != nil { @@ -62,32 +75,28 @@ func (service serviceSend) SendText(ctx context.Context, request domainSend.Mess // Reply message if request.ReplyMessageID != nil && *request.ReplyMessageID != "" { - participantJID := dataWaRecipient.String() - if len(*request.ReplyMessageID) < 28 { - firstDevice, err := service.appService.FirstDevice(ctx) - if err != nil { - return response, err - } - participantJID = firstDevice.Device - } - - msg.ExtendedTextMessage = &waE2E.ExtendedTextMessage{ - Text: proto.String(request.Message), - ContextInfo: &waE2E.ContextInfo{ - StanzaID: request.ReplyMessageID, - Participant: proto.String(participantJID), - QuotedMessage: &waE2E.Message{ - Conversation: proto.String(request.Message), + record, err := utils.FindRecordFromStorage(*request.ReplyMessageID) + if err == nil { // Only set reply context if we found the message ID + msg.ExtendedTextMessage = &waE2E.ExtendedTextMessage{ + Text: proto.String(request.Message), + ContextInfo: &waE2E.ContextInfo{ + StanzaID: request.ReplyMessageID, + Participant: proto.String(record.JID), + QuotedMessage: &waE2E.Message{ + Conversation: proto.String(record.MessageContent), + }, }, - }, - } + } - if len(parsedMentions) > 0 { - msg.ExtendedTextMessage.ContextInfo.MentionedJID = parsedMentions + if len(parsedMentions) > 0 { + msg.ExtendedTextMessage.ContextInfo.MentionedJID = parsedMentions + } + } else { + logrus.Warnf("Reply message ID %s not found in storage, continuing without reply context", *request.ReplyMessageID) } } - ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg) + ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, request.Message) if err != nil { return response, err } @@ -180,7 +189,12 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima FileLength: proto.Uint64(uint64(len(dataWaImage))), ViewOnce: proto.Bool(request.ViewOnce), }} - ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg) + + caption := "🖼️ Image" + if request.Caption != "" { + caption = "🖼️ " + request.Caption + } + ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, caption) go func() { errDelete := utils.RemoveFile(0, deletedItems...) if errDelete != nil { @@ -228,7 +242,11 @@ func (service serviceSend) SendFile(ctx context.Context, request domainSend.File DirectPath: proto.String(uploadedFile.DirectPath), Caption: proto.String(request.Caption), }} - ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg) + caption := "📄 Document" + if request.Caption != "" { + caption = "📄 " + request.Caption + } + ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, caption) if err != nil { return response, err } @@ -336,7 +354,11 @@ func (service serviceSend) SendVideo(ctx context.Context, request domainSend.Vid ThumbnailSHA256: dataWaThumbnail, ThumbnailDirectPath: proto.String(uploaded.DirectPath), }} - ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg) + caption := "🎥 Video" + if request.Caption != "" { + caption = "🎥 " + request.Caption + } + ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, caption) go func() { errDelete := utils.RemoveFile(1, deletedItems...) if errDelete != nil { @@ -368,7 +390,10 @@ func (service serviceSend) SendContact(ctx context.Context, request domainSend.C DisplayName: proto.String(request.ContactName), Vcard: proto.String(msgVCard), }} - ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg) + + content := "👤 " + request.ContactName + + ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content) if err != nil { return response, err } @@ -397,7 +422,11 @@ func (service serviceSend) SendLink(ctx context.Context, request domainSend.Link MatchedText: proto.String(request.Link), Description: proto.String(getMetaDataFromURL.Description), }} - ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg) + content := "🔗 " + request.Link + if request.Caption != "" { + content = "🔗 " + request.Caption + } + ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content) if err != nil { return response, err } @@ -425,8 +454,10 @@ func (service serviceSend) SendLocation(ctx context.Context, request domainSend. }, } + content := "📍 " + request.Latitude + ", " + request.Longitude + // Send WhatsApp Message Proto - ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg) + ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content) if err != nil { return response, err } @@ -467,7 +498,9 @@ func (service serviceSend) SendAudio(ctx context.Context, request domainSend.Aud }, } - ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, msg) + content := "🎵 Audio" + + ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content) if err != nil { return response, err } @@ -487,7 +520,9 @@ func (service serviceSend) SendPoll(ctx context.Context, request domainSend.Poll return response, err } - ts, err := service.WaCli.SendMessage(ctx, dataWaRecipient, service.WaCli.BuildPollCreation(request.Question, request.Options, request.MaxAnswer)) + content := "📊 " + request.Question + + ts, err := service.wrapSendMessage(ctx, dataWaRecipient, service.WaCli.BuildPollCreation(request.Question, request.Options, request.MaxAnswer), content) if err != nil { return response, err } @@ -497,6 +532,22 @@ func (service serviceSend) SendPoll(ctx context.Context, request domainSend.Poll return response, nil } +func (service serviceSend) SendPresence(ctx context.Context, request domainSend.PresenceRequest) (response domainSend.GenericResponse, err error) { + err = validations.ValidateSendPresence(ctx, request) + if err != nil { + return response, err + } + + err = service.WaCli.SendPresence(types.Presence(request.Type)) + if err != nil { + return response, err + } + + response.MessageID = "presence" + response.Status = fmt.Sprintf("Send presence success %s", request.Type) + return response, nil +} + func (service serviceSend) getMentionFromText(_ context.Context, messages string) (result []string) { mentions := utils.ContainsMention(messages) for _, mention := range mentions { @@ -506,7 +557,6 @@ func (service serviceSend) getMentionFromText(_ context.Context, messages string } } return result - } func (service serviceSend) uploadMedia(ctx context.Context, mediaType whatsmeow.MediaType, media []byte, recipient types.JID) (uploaded whatsmeow.UploadResponse, err error) { diff --git a/src/services/user.go b/src/services/user.go index 19d0652..b6636bb 100644 --- a/src/services/user.go +++ b/src/services/user.go @@ -1,16 +1,20 @@ package services import ( + "bytes" "context" "errors" "fmt" + "image" + "time" + domainUser "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/user" pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/whatsapp" "github.com/aldinokemal/go-whatsapp-web-multidevice/validations" + "github.com/disintegration/imaging" "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/types" - "time" ) type userService struct { @@ -155,3 +159,57 @@ func (service userService) MyPrivacySetting(_ context.Context) (response domainU response.Profile = string(resp.Profile) return response, nil } + +func (service userService) ChangeAvatar(ctx context.Context, request domainUser.ChangeAvatarRequest) (err error) { + whatsapp.MustLogin(service.WaCli) + + file, err := request.Avatar.Open() + if err != nil { + return err + } + defer file.Close() + + // Read original image + srcImage, err := imaging.Decode(file) + if err != nil { + return fmt.Errorf("failed to decode image: %v", err) + } + + // Get original dimensions + bounds := srcImage.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + // Calculate new dimensions for 1:1 aspect ratio + size := width + if height < width { + size = height + } + if size > 640 { + size = 640 + } + + // Create a square crop from the center + left := (width - size) / 2 + top := (height - size) / 2 + croppedImage := imaging.Crop(srcImage, image.Rect(left, top, left+size, top+size)) + + // Resize if needed + if size > 640 { + croppedImage = imaging.Resize(croppedImage, 640, 640, imaging.Lanczos) + } + + // Convert to bytes + var buf bytes.Buffer + err = imaging.Encode(&buf, croppedImage, imaging.JPEG, imaging.JPEGQuality(80)) + if err != nil { + return fmt.Errorf("failed to encode image: %v", err) + } + + _, err = service.WaCli.SetGroupPhoto(types.JID{}, buf.Bytes()) + if err != nil { + return err + } + + return nil +} diff --git a/src/validations/message_validation.go b/src/validations/message_validation.go index 358cce2..35bff40 100644 --- a/src/validations/message_validation.go +++ b/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 +} diff --git a/src/validations/send_validation.go b/src/validations/send_validation.go index 137704c..ee67368 100644 --- a/src/validations/send_validation.go +++ b/src/validations/send_validation.go @@ -3,6 +3,7 @@ package validations import ( "context" "fmt" + "github.com/aldinokemal/go-whatsapp-web-multidevice/config" domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send" pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" @@ -205,3 +206,15 @@ func ValidateSendPoll(ctx context.Context, request domainSend.PollRequest) error return nil } + +func ValidateSendPresence(ctx context.Context, request domainSend.PresenceRequest) error { + err := validation.ValidateStructWithContext(ctx, &request, + validation.Field(&request.Type, validation.In("available", "unavailable")), + ) + + if err != nil { + return pkgError.ValidationError(err.Error()) + } + + return nil +} diff --git a/src/views/assets/app.css b/src/views/assets/app.css new file mode 100644 index 0000000..f9ce165 --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/src/views/components/AccountAvatar.js b/src/views/components/AccountAvatar.js index e9ab15f..9d7b292 100644 --- a/src/views/components/AccountAvatar.js +++ b/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: `
+ Account
Avatar
You can search someone avatar by phone @@ -89,8 +101,8 @@ export default {
- diff --git a/src/views/components/AccountChangeAvatar.js b/src/views/components/AccountChangeAvatar.js new file mode 100644 index 0000000..6c8596b --- /dev/null +++ b/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: ` +
+
+ Account +
Change Avatar
+
+ Update your profile picture +
+
+
+ + + + ` +} diff --git a/src/views/components/AccountPrivacy.js b/src/views/components/AccountPrivacy.js index 46061b8..58d35ab 100644 --- a/src/views/components/AccountPrivacy.js +++ b/src/views/components/AccountPrivacy.js @@ -30,6 +30,7 @@ export default { template: `
+ Account
My Privacy Setting
Get your privacy settings diff --git a/src/views/components/AccountUserInfo.js b/src/views/components/AccountUserInfo.js index 8e307bf..56b5494 100644 --- a/src/views/components/AccountUserInfo.js +++ b/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: `
+ Account
User Info
You can search someone user info by phone @@ -81,8 +93,8 @@ export default {
- diff --git a/src/views/components/AppLogin.js b/src/views/components/AppLogin.js index 3300af6..76de2d6 100644 --- a/src/views/components/AppLogin.js +++ b/src/views/components/AppLogin.js @@ -41,6 +41,7 @@ export default { template: `
+ App
Login
Scan your QR code to access all API capabilities. diff --git a/src/views/components/AppLoginWithCode.js b/src/views/components/AppLoginWithCode.js index 8421e02..8495e42 100644 --- a/src/views/components/AppLoginWithCode.js +++ b/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: `
+ App
Login with Code
Enter your pairing code to log in and access your devices. diff --git a/src/views/components/AppLogout.js b/src/views/components/AppLogout.js index 1e87c8f..41956eb 100644 --- a/src/views/components/AppLogout.js +++ b/src/views/components/AppLogout.js @@ -28,6 +28,7 @@ export default { template: `
+ App
Logout
Remove your login session in application diff --git a/src/views/components/AppReconnect.js b/src/views/components/AppReconnect.js index 1d41451..695f3f1 100644 --- a/src/views/components/AppReconnect.js +++ b/src/views/components/AppReconnect.js @@ -26,6 +26,7 @@ export default { template: `
+ App
Reconnect
Please reconnect to the WhatsApp service if your API doesn't work or if your app is down. diff --git a/src/views/components/GroupCreate.js b/src/views/components/GroupCreate.js index e4c66ed..af14d03 100644 --- a/src/views/components/GroupCreate.js +++ b/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 {
-
+
+
` diff --git a/src/views/components/GroupJoinWithLink.js b/src/views/components/GroupJoinWithLink.js index 77eb9bf..88639af 100644 --- a/src/views/components/GroupJoinWithLink.js +++ b/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 {
-
+
+
` diff --git a/src/views/components/GroupManageParticipants.js b/src/views/components/GroupManageParticipants.js index 3c49ff9..483b0d9 100644 --- a/src/views/components/GroupManageParticipants.js +++ b/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 {
-
+
+
`, diff --git a/src/views/components/MessageDelete.js b/src/views/components/MessageDelete.js index 12cd6e6..15f1bc9 100644 --- a/src/views/components/MessageDelete.js +++ b/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 {
-
+
+
` diff --git a/src/views/components/MessageReact.js b/src/views/components/MessageReact.js index fdc23eb..3aa94a6 100644 --- a/src/views/components/MessageReact.js +++ b/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 {
-
Send -
+
` diff --git a/src/views/components/MessageRevoke.js b/src/views/components/MessageRevoke.js index 834706f..4a4f709 100644 --- a/src/views/components/MessageRevoke.js +++ b/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 {
-
Send -
+
` diff --git a/src/views/components/MessageUpdate.js b/src/views/components/MessageUpdate.js index a949772..eba8ad2 100644 --- a/src/views/components/MessageUpdate.js +++ b/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 {
-
Update -
+
` diff --git a/src/views/components/SendAudio.js b/src/views/components/SendAudio.js index c237e1a..b68f63e 100644 --- a/src/views/components/SendAudio.js +++ b/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: `
@@ -80,20 +103,26 @@ export default {
- + +
+
+ + Selected file: {{ selectedFileName }} +
+
-
+
+
` diff --git a/src/views/components/SendContact.js b/src/views/components/SendContact.js index 21b899d..7758302 100644 --- a/src/views/components/SendContact.js +++ b/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 {
-
+
+
` diff --git a/src/views/components/SendFile.js b/src/views/components/SendFile.js index affbb4c..a5de1dc 100644 --- a/src/views/components/SendFile.js +++ b/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: `
@@ -96,20 +119,26 @@ export default {
- + +
+
+ + Selected file: {{ selectedFileName }} +
+
-
+
+
` diff --git a/src/views/components/SendImage.js b/src/views/components/SendImage.js index 5c276c9..35682f2 100644 --- a/src/views/components/SendImage.js +++ b/src/views/components/SendImage.js @@ -13,13 +13,14 @@ export default { caption: '', type: window.TYPEUSER, loading: false, - selected_file: null + selected_file: null, + preview_url: null } }, computed: { phone_id() { return this.phone + this.type; - } + }, }, methods: { openModal() { @@ -29,7 +30,25 @@ export default { } }).modal('show'); }, + isShowAttributes() { + return this.type !== window.TYPESTATUS; + }, + isValidForm() { + if (this.type !== window.TYPESTATUS && !this.phone.trim()) { + return false; + } + + if (!this.selected_file) { + return false; + } + + return true; + }, async handleSubmit() { + if (!this.isValidForm() || this.loading) { + return; + } + try { let response = await this.submitApi() showSuccessInfo(response) @@ -65,9 +84,24 @@ export default { this.compress = false; this.phone = ''; this.caption = ''; - this.type = window.TYPEUSER; + this.preview_url = null; + this.selected_file = null; $("#file_image").val(''); }, + handleImageChange(event) { + const file = event.target.files[0]; + if (file) { + this.preview_url = URL.createObjectURL(file); + // Add small delay to allow DOM update before scrolling + setTimeout(() => { + const modalContent = document.querySelector('#modalSendImage .content'); + if (modalContent) { + modalContent.scrollTop = modalContent.scrollHeight; + } + this.selected_file = file.name; + }, 100); + } + } }, template: `
@@ -88,23 +122,23 @@ export default {
Send Image
-
+
- +
-
+
-
+
@@ -113,20 +147,24 @@ export default {
- + +
+ +
-
+
+
` diff --git a/src/views/components/SendLocation.js b/src/views/components/SendLocation.js index 68c3be2..226bbc1 100644 --- a/src/views/components/SendLocation.js +++ b/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 {
-
-
-
+
+
` diff --git a/src/views/components/SendMessage.js b/src/views/components/SendMessage.js index 63c1616..15cfd1d 100644 --- a/src/views/components/SendMessage.js +++ b/src/views/components/SendMessage.js @@ -17,7 +17,7 @@ export default { computed: { phone_id() { return this.phone + this.type; - } + }, }, methods: { openModal() { @@ -27,13 +27,33 @@ export default { } }).modal('show'); }, + isShowReplyId() { + return this.type !== window.TYPESTATUS; + }, + isValidForm() { + // Validate phone number is not empty except for status type + const isPhoneValid = this.type === window.TYPESTATUS || this.phone.trim().length > 0; + + // Validate message is not empty and has reasonable length + const isMessageValid = this.text.trim().length > 0 && this.text.length <= 4096; + + // Validate reply_message_id format if provided + const isReplyIdValid = this.reply_message_id === '' || + /^[A-F0-9]{32}\/[A-F0-9]{20}$/.test(this.reply_message_id); + + return isPhoneValid && isMessageValid && isReplyIdValid; + }, async handleSubmit() { + // Add validation check here to prevent submission when form is invalid + if (!this.isValidForm() || this.loading) { + return; + } try { - let response = await this.submitApi() - showSuccessInfo(response) + const response = await this.submitApi(); + showSuccessInfo(response); $('#modalSendMessage').modal('hide'); } catch (err) { - showErrorInfo(err) + showErrorInfo(err); } }, async submitApi() { @@ -41,20 +61,20 @@ export default { try { const payload = { phone: this.phone_id, - message: this.text, - } + message: this.text.trim(), + }; if (this.reply_message_id !== '') { payload.reply_message_id = this.reply_message_id; } - let response = await window.http.post(`/send/message`, payload) + const response = await window.http.post('/send/message', payload); this.handleReset(); return response.data.message; } catch (error) { - if (error.response) { + if (error.response?.data?.message) { throw new Error(error.response.data.message); } - throw new Error(error.message); + throw error; } finally { this.loading = false; } @@ -62,7 +82,6 @@ export default { handleReset() { this.phone = ''; this.text = ''; - this.type = window.TYPEUSER; this.reply_message_id = ''; }, }, @@ -85,8 +104,8 @@ export default {
- -
+ +
-
+
+
` diff --git a/src/views/components/SendPoll.js b/src/views/components/SendPoll.js index ebedf32..eec01bd 100644 --- a/src/views/components/SendPoll.js +++ b/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 {
-
+
+
` diff --git a/src/views/components/SendPresence.js b/src/views/components/SendPresence.js new file mode 100644 index 0000000..1e57764 --- /dev/null +++ b/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: ` +
+
+ Send +
Send Presence
+
+ Set
available
or
unavailable
+
+
+
+ + + + ` +} \ No newline at end of file diff --git a/src/views/components/SendVideo.js b/src/views/components/SendVideo.js index 6a93593..d00dc60 100644 --- a/src/views/components/SendVideo.js +++ b/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: `
@@ -96,21 +131,21 @@ export default {
- +
-
+
-
+
@@ -119,20 +154,27 @@ export default {
- + +
+
+ + Selected file: {{ selectedFileName }} +
+
-
+
+
` diff --git a/src/views/components/generic/FormRecipient.js b/src/views/components/generic/FormRecipient.js index d763e52..e937de7 100644 --- a/src/views/components/generic/FormRecipient.js +++ b/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 {
-
+
diff --git a/src/views/index.html b/src/views/index.html index e9708ab..67590cf 100644 --- a/src/views/index.html +++ b/src/views/index.html @@ -8,6 +8,47 @@ + + @@ -15,21 +56,33 @@ - Whatsapp API Multi Device {{ .AppVersion }} - - + WhatsApp API {{ .AppVersion }} -
-

Whatsapp API Multi Device ({{ .AppVersion }})

+
+ +

WhatsApp API

+
+
+ + +
+ + +
+
or you can upload image from your device
From dedd021cca21a9de056eb08cec29d22b45413603 Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Fri, 7 Feb 2025 13:09:01 +0700 Subject: [PATCH 6/6] feat: Add chat storage auto-flush mechanism - Introduce new configuration option `--chat-flush-interval` to control chat storage cleanup - Implement periodic chat storage flushing with configurable interval (default 7 days) - Migrate chat storage from text to CSV format for better data management - Add thread-safe file handling for chat storage operations - Update root command to support new chat flush interval flag --- src/cmd/root.go | 4 + src/config/settings.go | 15 ++-- src/internal/rest/helpers/flushChatCsv.go | 46 ++++++++++ src/pkg/utils/chat_storage.go | 104 +++++++++++----------- src/views/components/SendMessage.js | 6 +- 5 files changed, 110 insertions(+), 65 deletions(-) create mode 100644 src/internal/rest/helpers/flushChatCsv.go diff --git a/src/cmd/root.go b/src/cmd/root.go index 23215ab..858a85c 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -52,6 +52,7 @@ func init() { rootCmd.PersistentFlags().StringVarP(&config.WhatsappWebhookSecret, "webhook-secret", "", config.WhatsappWebhookSecret, `secure webhook request --webhook-secret | example: --webhook-secret="super-secret-key"`) rootCmd.PersistentFlags().BoolVarP(&config.WhatsappAccountValidation, "account-validation", "", config.WhatsappAccountValidation, `enable or disable account validation --account-validation | 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 | 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 | example: --chat-flush-interval=7`) } func runRest(_ *cobra.Command, _ []string) { @@ -148,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()) } diff --git a/src/config/settings.go b/src/config/settings.go index f35b027..4de0fd3 100644 --- a/src/config/settings.go +++ b/src/config/settings.go @@ -5,18 +5,19 @@ import ( ) var ( - AppVersion = "v5.1.0" - 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" - PathChatStorage = "storages/chat.txt" + PathChatStorage = "storages/chat.csv" DBURI = "file:storages/whatsapp.db?_foreign_keys=off" diff --git a/src/internal/rest/helpers/flushChatCsv.go b/src/internal/rest/helpers/flushChatCsv.go new file mode 100644 index 0000000..5f1c9a3 --- /dev/null +++ b/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) +} diff --git a/src/pkg/utils/chat_storage.go b/src/pkg/utils/chat_storage.go index 9d8ea89..e717e29 100644 --- a/src/pkg/utils/chat_storage.go +++ b/src/pkg/utils/chat_storage.go @@ -1,12 +1,12 @@ package utils import ( + "encoding/csv" "fmt" "os" - "strings" + "sync" "github.com/aldinokemal/go-whatsapp-web-multidevice/config" - "github.com/gofiber/fiber/v2/log" ) type RecordedMessage struct { @@ -15,30 +15,41 @@ type RecordedMessage struct { MessageContent string `json:"message_content,omitempty"` } +// mutex to prevent concurrent file access +var fileMutex sync.Mutex + func FindRecordFromStorage(messageID string) (RecordedMessage, error) { - data, err := os.ReadFile(config.PathChatStorage) + fileMutex.Lock() + defer fileMutex.Unlock() + + file, err := os.OpenFile(config.PathChatStorage, os.O_RDONLY|os.O_CREATE, 0644) if err != nil { - return RecordedMessage{}, err + return RecordedMessage{}, fmt.Errorf("failed to open storage file: %w", err) } + defer file.Close() - lines := strings.Split(string(data), "\n") - for _, line := range lines { - if line == "" { - continue - } - parts := strings.Split(line, ",") - if len(parts) == 3 && parts[0] == messageID { + 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: parts[0], - JID: parts[1], - MessageContent: parts[2], + 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) { +func RecordMessage(messageID string, senderJID string, messageContent string) error { + fileMutex.Lock() + defer fileMutex.Unlock() + message := RecordedMessage{ MessageID: messageID, JID: senderJID, @@ -46,53 +57,40 @@ func RecordMessage(messageID string, senderJID string, messageContent string) { } // Read existing messages - var messages []RecordedMessage - if data, err := os.ReadFile(config.PathChatStorage); err == nil { - // Split file by newlines and parse each line - lines := strings.Split(string(data), "\n") - for _, line := range lines { - if line == "" { - continue - } - parts := strings.Split(line, ",") + 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) + } - msg := RecordedMessage{ - MessageID: parts[0], - JID: parts[1], - MessageContent: parts[2], + // Check for duplicates + for _, record := range records { + if len(record) == 3 && record[0] == messageID { + return nil // Skip if duplicate found } - messages = append(messages, msg) } } - // Check for duplicates - for _, msg := range messages { - if msg.MessageID == message.MessageID { - return // 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 new message at the top - f, err := os.OpenFile(config.PathChatStorage, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + // 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 { - log.Errorf("Failed to open received-chat.txt: %v", err) - return + return fmt.Errorf("failed to open file for writing: %w", err) } - defer f.Close() + defer file.Close() - // Write new message first - csvLine := fmt.Sprintf("%s,%s,%s\n", message.MessageID, message.JID, message.MessageContent) - if _, err := f.WriteString(csvLine); err != nil { - log.Errorf("Failed to write to received-chat.txt: %v", err) - return - } + writer := csv.NewWriter(file) + defer writer.Flush() - // Write existing messages after - for _, msg := range messages { - csvLine := fmt.Sprintf("%s,%s,%s\n", msg.MessageID, msg.JID, msg.MessageContent) - if _, err := f.WriteString(csvLine); err != nil { - log.Errorf("Failed to write to received-chat.txt: %v", err) - return - } + if err := writer.WriteAll(records); err != nil { + return fmt.Errorf("failed to write CSV records: %w", err) } + + return nil } diff --git a/src/views/components/SendMessage.js b/src/views/components/SendMessage.js index 15cfd1d..2fffa25 100644 --- a/src/views/components/SendMessage.js +++ b/src/views/components/SendMessage.js @@ -36,12 +36,8 @@ export default { // Validate message is not empty and has reasonable length const isMessageValid = this.text.trim().length > 0 && this.text.length <= 4096; - - // Validate reply_message_id format if provided - const isReplyIdValid = this.reply_message_id === '' || - /^[A-F0-9]{32}\/[A-F0-9]{20}$/.test(this.reply_message_id); - return isPhoneValid && isMessageValid && isReplyIdValid; + return isPhoneValid && isMessageValid }, async handleSubmit() { // Add validation check here to prevent submission when form is invalid