Browse Source

Merge branch 'main' into bug/large_groups

pull/254/head
almogbaku 10 months ago
parent
commit
90a02df20d
No known key found for this signature in database GPG Key ID: 66C92B1C5B475512
  1. 194
      docs/openapi.yaml
  2. 95
      readme.md
  3. 2
      src/.air.toml
  4. 4
      src/config/settings.go
  5. 19
      src/domains/group/group.go
  6. 5
      src/domains/send/audio.go
  7. 1
      src/domains/send/contact.go
  8. 7
      src/domains/send/file.go
  9. 13
      src/domains/send/image.go
  10. 7
      src/domains/send/link.go
  11. 7
      src/domains/send/location.go
  12. 3
      src/domains/send/presence.go
  13. 1
      src/domains/send/text.go
  14. 11
      src/domains/send/video.go
  15. 4
      src/domains/user/account.go
  16. 1
      src/domains/user/user.go
  17. 47
      src/go.mod
  18. 120
      src/go.sum
  19. 83
      src/internal/rest/group.go
  20. 16
      src/internal/rest/user.go
  21. 155
      src/pkg/utils/chat_storage_test.go
  22. 185
      src/pkg/utils/environment_test.go
  23. 138
      src/pkg/utils/general.go
  24. 66
      src/services/group.go
  25. 3
      src/services/message.go
  26. 108
      src/services/send.go
  27. 11
      src/services/user.go
  28. 29
      src/validations/group_validation.go
  29. 4
      src/views/assets/app.css
  30. 96
      src/views/components/AccountChangePushName.js
  31. 35
      src/views/components/AccountContact.js
  32. 136
      src/views/components/GroupList.js
  33. 14
      src/views/components/SendAudio.js
  34. 20
      src/views/components/SendContact.js
  35. 15
      src/views/components/SendFile.js
  36. 20
      src/views/components/SendImage.js
  37. 148
      src/views/components/SendLink.js
  38. 15
      src/views/components/SendLocation.js
  39. 10
      src/views/components/SendMessage.js
  40. 18
      src/views/components/SendVideo.js
  41. 13
      src/views/index.html

194
docs/openapi.yaml

@ -1,7 +1,7 @@
openapi: "3.0.0"
info:
title: WhatsApp API MultiDevice
version: 5.2.0
version: 5.4.0
description: This API is used for sending whatsapp via API
servers:
- url: http://localhost:3000
@ -18,6 +18,9 @@ tags:
description: Group setting
- name: newsletter
description: newsletter setting
security:
- basicAuth: []
paths:
/app/login:
get:
@ -225,6 +228,44 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorInternalServer'
/user/pushname:
post:
operationId: userChangePushName
tags:
- user
summary: User Change Push Name
description: Update the display name (push name) shown to others in WhatsApp
requestBody:
content:
application/json:
schema:
type: object
properties:
push_name:
type: string
example: 'John Doe'
description: The new display name to set
required:
- push_name
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
@ -1179,6 +1220,123 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorInternalServer'
/group/participant-requests:
get:
operationId: getGroupParticipantRequests
tags:
- group
summary: Get list of participant requests to join group
parameters:
- name: group_id
in: query
required: true
schema:
type: string
example: '120363024512399999@g.us'
description: The group ID to get participant requests for
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/GroupParticipantRequestListResponse'
'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'
/group/participant-requests/approve:
post:
operationId: approveGroupParticipantRequest
tags:
- group
summary: Approve participant request to join group
requestBody:
content:
application/json:
schema:
type: object
properties:
group_id:
type: string
example: '120363024512399999@g.us'
description: The group ID
participant_id:
type: string
example: '6281234567890'
description: The participant's WhatsApp ID to approve
required:
- group_id
- participant_id
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'
/group/participant-requests/reject:
post:
operationId: rejectGroupParticipantRequest
tags:
- group
summary: Reject participant request to join group
requestBody:
content:
application/json:
schema:
type: object
properties:
group_id:
type: string
example: '120363024512399999@g.us'
description: The group ID
participant_id:
type: string
example: '6281234567890'
description: The participant's WhatsApp ID to reject
required:
- group_id
- participant_id
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'
/group/leave:
post:
operationId: leaveGroup
@ -1249,6 +1407,10 @@ paths:
$ref: '#/components/schemas/ErrorInternalServer'
components:
securitySchemes:
basicAuth:
type: http
scheme: basic
schemas:
CreateGroupResponse:
type: object
@ -1281,6 +1443,7 @@ components:
- '6839241294719274'
ManageParticipantResponse:
type: object
additionalProperties: false
properties:
code:
type: string
@ -1291,6 +1454,8 @@ components:
results:
type: array
items:
type: object
additionalProperties: false
properties:
participant:
type: string
@ -1813,4 +1978,29 @@ components:
example: 0
AddRequest:
type: string
example: null
example: null
GroupParticipantRequestListResponse:
type: object
properties:
code:
type: string
example: "SUCCESS"
message:
type: string
example: "Success getting list requested participants"
results:
type: object
properties:
data:
type: array
items:
type: object
properties:
jid:
type: string
example: "6289685024091@s.whatsapp.net"
requested_at:
type: string
format: date-time
example: "2024-10-11T21:27:29+07:00"

95
readme.md

@ -1,5 +1,10 @@
# WhatsApp API Multi Device Version
[![Patreon](https://img.shields.io/badge/Support%20on-Patreon-orange.svg)](https://www.patreon.com/c/aldinokemal)
**If you're using this tools to generate income, consider supporting its development by becoming a Patreon member!**
Your support helps ensure the library stays maintained and receives regular updates!
___
![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)
@ -39,13 +44,14 @@ Now that we support ARM64 for Linux:
- `-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"`
## Configuration
You can configure the application using either command-line flags (shown above) or environment variables. Configuration can be set in three ways (in order of priority):
You can configure the application using either command-line flags (shown above) or environment variables. Configuration
can be set in three ways (in order of priority):
1. Command-line flags (highest priority)
2. Environment variables
@ -176,44 +182,48 @@ You can fork or edit this source code !
- Use [SwaggerEditor](https://editor.swagger.io) to visualize the API.
- Generate HTTP clients using [openapi-generator](https://openapi-generator.tech/#try).
| 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 |
| ✅ | 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 |
| ✅ | User My Contacts | GET | /user/my/contacts |
| ✅ | 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 Contact | POST | /send/contact |
| ✅ | Send Link | POST | /send/link |
| ✅ | Send Location | POST | /send/location |
| ✅ | Send Poll / Vote | POST | /send/poll |
| ✅ | Send Presence | POST | /send/presence |
| ✅ | Revoke Message | POST | /message/:message_id/revoke |
| ✅ | React Message | POST | /message/:message_id/reaction |
| ✅ | Delete Message | POST | /message/:message_id/delete |
| ✅ | Edit Message | POST | /message/:message_id/update |
| ✅ | Read Message (DM) | POST | /message/:message_id/read |
| ❌ | Star Message | POST | /message/:message_id/star |
| ✅ | Join Group With Link | POST | /group/join-with-link |
| ✅ | Leave Group | POST | /group/leave |
| ✅ | Create Group | POST | /group |
| ✅ | Add Participants in Group | POST | /group/participants |
| ✅ | Remove Participant in Group | POST | /group/participants/remove |
| ✅ | Promote Participant in Group | POST | /group/participants/promote |
| ✅ | Demote Participant in Group | POST | /group/participants/demote |
| ✅ | Unfollow Newsletter | POST | /newsletter/unfollow |
| 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 |
| ✅ | User Info | GET | /user/info |
| ✅ | User Avatar | GET | /user/avatar |
| ✅ | User Change Avatar | POST | /user/avatar |
| ✅ | User Change PushName | POST | /user/pushname |
| ✅ | User My Groups | GET | /user/my/groups |
| ✅ | User My Newsletter | GET | /user/my/newsletters |
| ✅ | User My Privacy Setting | GET | /user/my/privacy |
| ✅ | User My Contacts | GET | /user/my/contacts |
| ✅ | Send Message | POST | /send/message |
| ✅ | Send Image | POST | /send/image |
| ✅ | Send Audio | POST | /send/audio |
| ✅ | Send File | POST | /send/file |
| ✅ | Send Video | POST | /send/video |
| ✅ | Send Contact | POST | /send/contact |
| ✅ | Send Link | POST | /send/link |
| ✅ | Send Location | POST | /send/location |
| ✅ | Send Poll / Vote | POST | /send/poll |
| ✅ | Send Presence | POST | /send/presence |
| ✅ | Revoke Message | POST | /message/:message_id/revoke |
| ✅ | React Message | POST | /message/:message_id/reaction |
| ✅ | Delete Message | POST | /message/:message_id/delete |
| ✅ | Edit Message | POST | /message/:message_id/update |
| ✅ | Read Message (DM) | POST | /message/:message_id/read |
| ✅ | Star Message | POST | /message/:message_id/star |
| ✅ | Join Group With Link | POST | /group/join-with-link |
| ✅ | Leave Group | POST | /group/leave |
| ✅ | Create Group | POST | /group |
| ✅ | Add Participants in Group | POST | /group/participants |
| ✅ | Remove Participant in Group | POST | /group/participants/remove |
| ✅ | Promote Participant in Group | POST | /group/participants/promote |
| ✅ | Demote Participant in Group | POST | /group/participants/demote |
| ✅ | List Requested Participants in Group | POST | /group/participants/requested |
| ✅ | Approve Requested Participant in Group | POST | /group/participants/requested/approve |
| ✅ | Reject Requested Participant in Group | POST | /group/participants/requested/reject |
| ✅ | Unfollow Newsletter | POST | /newsletter/unfollow |
```txt
✅ = Available
@ -255,3 +265,8 @@ You can fork or edit this source code !
- Please do this if you have an error (invalid flag in pkg-config --cflags: -Xpreprocessor)
`export CGO_CFLAGS_ALLOW="-Xpreprocessor"`
## Important
- This project is unofficial and not affiliated with WhatsApp.
- Please use official WhatsApp API to avoid any issues.

2
src/.air.toml

@ -2,4 +2,4 @@ root = '.'
tmp_dir = "tmp"
[build]
exclude_dir = ["statics", "views"]
exclude_dir = ["statics", "storages"]

4
src/config/settings.go

@ -5,7 +5,7 @@ import (
)
var (
AppVersion = "v5.3.0"
AppVersion = "v5.6.1"
AppPort = "3000"
AppDebug = false
AppOs = "AldinoKemal"
@ -19,7 +19,7 @@ var (
PathStorages = "storages"
PathChatStorage = "storages/chat.csv"
DBURI = "file:storages/whatsapp.db?_foreign_keys=off"
DBURI = "file:storages/whatsapp.db?_foreign_keys=on"
DBKeysURI = ""
WhatsappAutoReplyMessage string

19
src/domains/group/group.go

@ -2,6 +2,8 @@ package group
import (
"context"
"time"
"go.mau.fi/whatsmeow"
)
@ -10,6 +12,8 @@ type IGroupService interface {
LeaveGroup(ctx context.Context, request LeaveGroupRequest) (err error)
CreateGroup(ctx context.Context, request CreateGroupRequest) (groupID string, err error)
ManageParticipant(ctx context.Context, request ParticipantRequest) (result []ParticipantStatus, err error)
GetGroupRequestParticipants(ctx context.Context, request GetGroupRequestParticipantsRequest) (result []GetGroupRequestParticipantsResponse, err error)
ManageGroupRequestParticipants(ctx context.Context, request GroupRequestParticipantsRequest) (result []ParticipantStatus, err error)
}
type JoinGroupWithLinkRequest struct {
@ -36,3 +40,18 @@ type ParticipantStatus struct {
Status string `json:"status"`
Message string `json:"message"`
}
type GetGroupRequestParticipantsRequest struct {
GroupID string `json:"group_id" query:"group_id"`
}
type GetGroupRequestParticipantsResponse struct {
JID string `json:"jid"`
RequestedAt time.Time `json:"requested_at"`
}
type GroupRequestParticipantsRequest struct {
GroupID string `json:"group_id" form:"group_id"`
Participants []string `json:"participants" form:"participants"`
Action whatsmeow.ParticipantRequestChange `json:"action" form:"action"`
}

5
src/domains/send/audio.go

@ -3,6 +3,7 @@ package send
import "mime/multipart"
type AudioRequest struct {
Phone string `json:"phone" form:"phone"`
Audio *multipart.FileHeader `json:"audio" form:"audio"`
Phone string `json:"phone" form:"phone"`
Audio *multipart.FileHeader `json:"audio" form:"audio"`
IsForwarded bool `json:"is_forwarded" form:"is_forwarded"`
}

1
src/domains/send/contact.go

@ -4,4 +4,5 @@ type ContactRequest struct {
Phone string `json:"phone" form:"phone"`
ContactName string `json:"contact_name" form:"contact_name"`
ContactPhone string `json:"contact_phone" form:"contact_phone"`
IsForwarded bool `json:"is_forwarded" form:"is_forwarded"`
}

7
src/domains/send/file.go

@ -3,7 +3,8 @@ package send
import "mime/multipart"
type FileRequest struct {
Phone string `json:"phone" form:"phone"`
File *multipart.FileHeader `json:"file" form:"file"`
Caption string `json:"caption" form:"caption"`
Phone string `json:"phone" form:"phone"`
File *multipart.FileHeader `json:"file" form:"file"`
Caption string `json:"caption" form:"caption"`
IsForwarded bool `json:"is_forwarded" form:"is_forwarded"`
}

13
src/domains/send/image.go

@ -3,10 +3,11 @@ package send
import "mime/multipart"
type ImageRequest struct {
Phone string `json:"phone" form:"phone"`
Caption string `json:"caption" form:"caption"`
Image *multipart.FileHeader `json:"image" form:"image"`
ImageURL *string `json:"image_url" form:"image_url"`
ViewOnce bool `json:"view_once" form:"view_once"`
Compress bool `json:"compress"`
Phone string `json:"phone" form:"phone"`
Caption string `json:"caption" form:"caption"`
Image *multipart.FileHeader `json:"image" form:"image"`
ImageURL *string `json:"image_url" form:"image_url"`
ViewOnce bool `json:"view_once" form:"view_once"`
Compress bool `json:"compress"`
IsForwarded bool `json:"is_forwarded" form:"is_forwarded"`
}

7
src/domains/send/link.go

@ -1,7 +1,8 @@
package send
type LinkRequest struct {
Phone string `json:"phone" form:"phone"`
Caption string `json:"caption"`
Link string `json:"link"`
Phone string `json:"phone" form:"phone"`
Caption string `json:"caption"`
Link string `json:"link"`
IsForwarded bool `json:"is_forwarded" form:"is_forwarded"`
}

7
src/domains/send/location.go

@ -1,7 +1,8 @@
package send
type LocationRequest struct {
Phone string `json:"phone" form:"phone"`
Latitude string `json:"latitude" form:"latitude"`
Longitude string `json:"longitude" form:"longitude"`
Phone string `json:"phone" form:"phone"`
Latitude string `json:"latitude" form:"latitude"`
Longitude string `json:"longitude" form:"longitude"`
IsForwarded bool `json:"is_forwarded" form:"is_forwarded"`
}

3
src/domains/send/presence.go

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

1
src/domains/send/text.go

@ -3,5 +3,6 @@ package send
type MessageRequest struct {
Phone string `json:"phone" form:"phone"`
Message string `json:"message" form:"message"`
IsForwarded bool `json:"is_forwarded" form:"is_forwarded"`
ReplyMessageID *string `json:"reply_message_id" form:"reply_message_id"`
}

11
src/domains/send/video.go

@ -3,9 +3,10 @@ package send
import "mime/multipart"
type VideoRequest struct {
Phone string `json:"phone" form:"phone"`
Caption string `json:"caption" form:"caption"`
Video *multipart.FileHeader `json:"video" form:"video"`
ViewOnce bool `json:"view_once" form:"view_once"`
Compress bool `json:"compress"`
Phone string `json:"phone" form:"phone"`
Caption string `json:"caption" form:"caption"`
Video *multipart.FileHeader `json:"video" form:"video"`
ViewOnce bool `json:"view_once" form:"view_once"`
Compress bool `json:"compress"`
IsForwarded bool `json:"is_forwarded" form:"is_forwarded"`
}

4
src/domains/user/account.go

@ -69,3 +69,7 @@ type MyListContactsResponseData struct {
JID types.JID `json:"jid"`
Name string `json:"name"`
}
type ChangePushNameRequest struct {
PushName string `json:"push_name" form:"push_name"`
}

1
src/domains/user/user.go

@ -8,6 +8,7 @@ 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)
ChangePushName(ctx context.Context, request ChangePushNameRequest) (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)

47
src/go.mod

@ -3,7 +3,7 @@ module github.com/aldinokemal/go-whatsapp-web-multidevice
go 1.24.0
require (
github.com/PuerkitoBio/goquery v1.10.2
github.com/PuerkitoBio/goquery v1.10.3
github.com/disintegration/imaging v1.6.2
github.com/dustin/go-humanize v1.0.1
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
@ -12,16 +12,17 @@ require (
github.com/gofiber/websocket/v2 v2.2.1
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.24
github.com/mattn/go-sqlite3 v1.14.28
github.com/sirupsen/logrus v1.9.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.19.0
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/valyala/fasthttp v1.59.0
github.com/valyala/fasthttp v1.62.0
go.mau.fi/libsignal v0.1.2
go.mau.fi/whatsmeow v0.0.0-20250225112721-b7530f3a5056
google.golang.org/protobuf v1.36.5
go.mau.fi/whatsmeow v0.0.0-20250501130609-4c93ee4e6efa
golang.org/x/image v0.27.0
google.golang.org/protobuf v1.36.6
)
require (
@ -31,39 +32,35 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/magiconair/properties v1.8.9 // 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/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.8.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.mau.fi/util v0.8.5 // indirect
go.mau.fi/util v0.8.6 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

120
src/go.sum

@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8=
github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
@ -25,8 +27,12 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
@ -44,8 +50,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@ -56,8 +60,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
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=
@ -69,10 +71,18 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a h1:S+AGcmAESQ0pXCUNnRH7V+bOUIgkSX5qVt2cNKCrm0Q=
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4=
github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
@ -82,32 +92,38 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sagikazarmark/locafero v0.8.0 h1:mXaMVw7IqxNBxfv3LdWt9MDmcWDQ1fagDH918lOdVaQ=
github.com/sagikazarmark/locafero v0.8.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 h1:qIQ0tWF9vxGtkJa24bR+2i53WBCz1nW/Pc47oVYauC4=
github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -119,15 +135,25 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.59.0 h1:Qu0qYHfXvPk1mSLNqcFtEk6DpxgA26hy6bmydotDpRI=
github.com/valyala/fasthttp v1.59.0/go.mod h1:GTxNb9Bc6r2a9D0TWNSPwDz78UxnTGBViY3xZNEqyYU=
github.com/valyala/fasthttp v1.60.0 h1:kBRYS0lOhVJ6V+bYN8PqAHELKHtXqwq9zNMLKx1MBsw=
github.com/valyala/fasthttp v1.60.0/go.mod h1:iY4kDgV3Gc6EqhRZ8icqcmlG6bqhcDXfuHgTO4FXCvc=
github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/libsignal v0.1.2 h1:Vs16DXWxSKyzVtI+EEXLCSy5pVWzzCzp/2eqFGvLyP0=
go.mau.fi/libsignal v0.1.2/go.mod h1:JpnLSSJptn/s1sv7I56uEMywvz8x4YzxeF5OzdPb6PE=
go.mau.fi/util v0.8.5 h1:PwCAAtcfK0XxZ4sdErJyfBMkTEWoQU33aB7QqDDzQRI=
go.mau.fi/util v0.8.5/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
go.mau.fi/whatsmeow v0.0.0-20250225112721-b7530f3a5056 h1:1JQUOpYXhFSEQgXMEWD/ZH38FrIe5i1yjxSBwa0aN/Q=
go.mau.fi/whatsmeow v0.0.0-20250225112721-b7530f3a5056/go.mod h1:6hRrUtDWI2wTRClOd6m17GwrFE2a8/p5R4pjJsIVn+U=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
go.mau.fi/whatsmeow v0.0.0-20250318233852-06705625cf82 h1:AZlDkXHgoQNW4gd2hnTCvPH7hYznmwc3gPaYqGZ5w8A=
go.mau.fi/whatsmeow v0.0.0-20250318233852-06705625cf82/go.mod h1:WNhj4JeQ6YR6dUOEiCXKqmE4LavSFkwRoKmu4atRrRs=
go.mau.fi/whatsmeow v0.0.0-20250402091807-b0caa1b76088 h1:ns6nk2NjqdaQnCKrp+Qqwpf+3OI7+nnH56D71+7XzOM=
go.mau.fi/whatsmeow v0.0.0-20250402091807-b0caa1b76088/go.mod h1:WNhj4JeQ6YR6dUOEiCXKqmE4LavSFkwRoKmu4atRrRs=
go.mau.fi/whatsmeow v0.0.0-20250417131650-164ddf482526 h1:i9w16FdM3zmOWdF5nh1l2MlmE/wK7ulL6rbT02WBBJs=
go.mau.fi/whatsmeow v0.0.0-20250417131650-164ddf482526/go.mod h1:NlPtoLdpX3RnltqCTCZQ6kIUfprqLirtSK1gHvwoNx0=
go.mau.fi/whatsmeow v0.0.0-20250501130609-4c93ee4e6efa h1:+bQKfMtnhX2jVoCSaneH4Ctk51IVT1K2gvjyqfFjVW0=
go.mau.fi/whatsmeow v0.0.0-20250501130609-4c93ee4e6efa/go.mod h1:NlPtoLdpX3RnltqCTCZQ6kIUfprqLirtSK1gHvwoNx0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -136,13 +162,23 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4=
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -157,8 +193,14 @@ 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/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
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=
@ -180,8 +222,12 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -200,8 +246,12 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@ -211,11 +261,11 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

83
src/internal/rest/group.go

@ -2,6 +2,7 @@ package rest
import (
"fmt"
domainGroup "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/group"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/whatsapp"
@ -22,6 +23,9 @@ func InitRestGroup(app *fiber.App, service domainGroup.IGroupService) Group {
app.Post("/group/participants/remove", rest.DeleteParticipants)
app.Post("/group/participants/promote", rest.PromoteParticipants)
app.Post("/group/participants/demote", rest.DemoteParticipants)
app.Get("/group/participant-requests", rest.ListParticipantRequests)
app.Post("/group/participant-requests/approve", rest.ApproveParticipantRequests)
app.Post("/group/participant-requests/reject", rest.RejectParticipantRequests)
return rest
}
@ -77,83 +81,86 @@ func (controller *Group) CreateGroup(c *fiber.Ctx) error {
},
})
}
func (controller *Group) AddParticipants(c *fiber.Ctx) error {
var request domainGroup.ParticipantRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
whatsapp.SanitizePhone(&request.GroupID)
return controller.manageParticipants(c, whatsmeow.ParticipantChangeAdd, "Success add participants")
}
request.Action = whatsmeow.ParticipantChangeAdd
func (controller *Group) DeleteParticipants(c *fiber.Ctx) error {
return controller.manageParticipants(c, whatsmeow.ParticipantChangeRemove, "Success delete participants")
}
result, err := controller.Service.ManageParticipant(c.UserContext(), request)
utils.PanicIfNeeded(err)
func (controller *Group) PromoteParticipants(c *fiber.Ctx) error {
return controller.manageParticipants(c, whatsmeow.ParticipantChangePromote, "Success promote participants")
}
return c.JSON(utils.ResponseData{
Status: 200,
Code: "SUCCESS",
Message: "Success add participants",
Results: result,
})
func (controller *Group) DemoteParticipants(c *fiber.Ctx) error {
return controller.manageParticipants(c, whatsmeow.ParticipantChangeDemote, "Success demote participants")
}
func (controller *Group) DeleteParticipants(c *fiber.Ctx) error {
var request domainGroup.ParticipantRequest
err := c.BodyParser(&request)
func (controller *Group) ListParticipantRequests(c *fiber.Ctx) error {
var request domainGroup.GetGroupRequestParticipantsRequest
err := c.QueryParser(&request)
utils.PanicIfNeeded(err)
whatsapp.SanitizePhone(&request.GroupID)
if request.GroupID == "" {
return c.Status(fiber.StatusBadRequest).JSON(utils.ResponseData{
Status: 400,
Code: "INVALID_GROUP_ID",
Message: "Group ID cannot be empty",
})
}
request.Action = whatsmeow.ParticipantChangeRemove
whatsapp.SanitizePhone(&request.GroupID)
result, err := controller.Service.ManageParticipant(c.UserContext(), request)
result, err := controller.Service.GetGroupRequestParticipants(c.UserContext(), request)
utils.PanicIfNeeded(err)
return c.JSON(utils.ResponseData{
Status: 200,
Code: "SUCCESS",
Message: "Success delete participants",
Message: "Success getting list requested participants",
Results: result,
})
}
func (controller *Group) PromoteParticipants(c *fiber.Ctx) error {
func (controller *Group) ApproveParticipantRequests(c *fiber.Ctx) error {
return controller.handleRequestedParticipants(c, whatsmeow.ParticipantChangeApprove, "Success approve requested participants")
}
func (controller *Group) RejectParticipantRequests(c *fiber.Ctx) error {
return controller.handleRequestedParticipants(c, whatsmeow.ParticipantChangeReject, "Success reject requested participants")
}
// Generalized participant management handler
func (controller *Group) manageParticipants(c *fiber.Ctx, action whatsmeow.ParticipantChange, successMsg string) error {
var request domainGroup.ParticipantRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
whatsapp.SanitizePhone(&request.GroupID)
request.Action = whatsmeow.ParticipantChangePromote
request.Action = action
result, err := controller.Service.ManageParticipant(c.UserContext(), request)
utils.PanicIfNeeded(err)
return c.JSON(utils.ResponseData{
Status: 200,
Code: "SUCCESS",
Message: "Success promote participants",
Message: successMsg,
Results: result,
})
}
func (controller *Group) DemoteParticipants(c *fiber.Ctx) error {
var request domainGroup.ParticipantRequest
// Generalized requested participants handler
func (controller *Group) handleRequestedParticipants(c *fiber.Ctx, action whatsmeow.ParticipantRequestChange, successMsg string) error {
var request domainGroup.GroupRequestParticipantsRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
whatsapp.SanitizePhone(&request.GroupID)
request.Action = whatsmeow.ParticipantChangeDemote
result, err := controller.Service.ManageParticipant(c.UserContext(), request)
request.Action = action
result, err := controller.Service.ManageGroupRequestParticipants(c.UserContext(), request)
utils.PanicIfNeeded(err)
return c.JSON(utils.ResponseData{
Status: 200,
Code: "SUCCESS",
Message: "Success demote participants",
Message: successMsg,
Results: result,
})
}

16
src/internal/rest/user.go

@ -16,6 +16,7 @@ func InitRestUser(app *fiber.App, service domainUser.IUserService) User {
app.Get("/user/info", rest.UserInfo)
app.Get("/user/avatar", rest.UserAvatar)
app.Post("/user/avatar", rest.UserChangeAvatar)
app.Post("/user/pushname", rest.UserChangePushName)
app.Get("/user/my/privacy", rest.UserMyPrivacySetting)
app.Get("/user/my/groups", rest.UserMyListGroups)
app.Get("/user/my/newsletters", rest.UserMyListNewsletter)
@ -125,3 +126,18 @@ func (controller *User) UserMyListContacts(c *fiber.Ctx) error {
Results: response,
})
}
func (controller *User) UserChangePushName(c *fiber.Ctx) error {
var request domainUser.ChangePushNameRequest
err := c.BodyParser(&request)
utils.PanicIfNeeded(err)
err = controller.Service.ChangePushName(c.UserContext(), request)
utils.PanicIfNeeded(err)
return c.JSON(utils.ResponseData{
Status: 200,
Code: "SUCCESS",
Message: "Success change push name",
})
}

155
src/pkg/utils/chat_storage_test.go

@ -0,0 +1,155 @@
package utils_test
import (
"encoding/csv"
"os"
"path/filepath"
"testing"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
. "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type ChatStorageTestSuite struct {
suite.Suite
tempDir string
origStorage bool
origPath string
}
func (suite *ChatStorageTestSuite) SetupTest() {
// Create a temporary directory for test files
tempDir, err := os.MkdirTemp("", "chat_storage_test")
assert.NoError(suite.T(), err)
suite.tempDir = tempDir
// Save original config values
suite.origStorage = config.WhatsappChatStorage
suite.origPath = config.PathChatStorage
// Set test config values
config.WhatsappChatStorage = true
config.PathChatStorage = filepath.Join(tempDir, "chat_storage.csv")
}
func (suite *ChatStorageTestSuite) TearDownTest() {
// Restore original config values
config.WhatsappChatStorage = suite.origStorage
config.PathChatStorage = suite.origPath
// Clean up temp directory
os.RemoveAll(suite.tempDir)
}
func (suite *ChatStorageTestSuite) createTestData() {
// Create test CSV data
file, err := os.Create(config.PathChatStorage)
assert.NoError(suite.T(), err)
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
testData := [][]string{
{"msg1", "user1@test.com", "Hello world"},
{"msg2", "user2@test.com", "Test message"},
}
err = writer.WriteAll(testData)
assert.NoError(suite.T(), err)
}
func (suite *ChatStorageTestSuite) TestFindRecordFromStorage() {
// Test case: Record found
suite.createTestData()
record, err := FindRecordFromStorage("msg1")
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "msg1", record.MessageID)
assert.Equal(suite.T(), "user1@test.com", record.JID)
assert.Equal(suite.T(), "Hello world", record.MessageContent)
// Test case: Record not found
_, err = FindRecordFromStorage("non_existent")
assert.Error(suite.T(), err)
assert.Contains(suite.T(), err.Error(), "not found in storage")
// Test case: Empty file - should still report message not found
os.Remove(config.PathChatStorage)
_, err = FindRecordFromStorage("msg1")
assert.Error(suite.T(), err)
assert.Contains(suite.T(), err.Error(), "not found in storage")
// Test case: Corrupted CSV file - should return CSV parsing error
err = os.WriteFile(config.PathChatStorage, []byte("corrupted,csv,data\nwith,no,proper,format"), 0644)
assert.NoError(suite.T(), err)
_, err = FindRecordFromStorage("msg1")
assert.Error(suite.T(), err)
assert.Contains(suite.T(), err.Error(), "failed to read CSV records")
// Test case: File permissions issue
// Create an unreadable directory for testing file permission issues
unreadableDir := filepath.Join(suite.tempDir, "unreadable")
err = os.Mkdir(unreadableDir, 0000)
assert.NoError(suite.T(), err)
defer os.Chmod(unreadableDir, 0755) // So it can be deleted during teardown
// Temporarily change path to unreadable location
origPath := config.PathChatStorage
config.PathChatStorage = filepath.Join(unreadableDir, "inaccessible.csv")
_, err = FindRecordFromStorage("anything")
assert.Error(suite.T(), err)
assert.Contains(suite.T(), err.Error(), "failed to open storage file")
// Restore path
config.PathChatStorage = origPath
}
func (suite *ChatStorageTestSuite) TestRecordMessage() {
// Test case: Normal recording
err := RecordMessage("newMsg", "user@test.com", "New test message")
assert.NoError(suite.T(), err)
// Verify the message was recorded
record, err := FindRecordFromStorage("newMsg")
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "newMsg", record.MessageID)
assert.Equal(suite.T(), "user@test.com", record.JID)
assert.Equal(suite.T(), "New test message", record.MessageContent)
// Test case: Duplicate message ID
err = RecordMessage("newMsg", "user@test.com", "Duplicate message")
assert.NoError(suite.T(), err)
// Verify the duplicate wasn't added
record, err = FindRecordFromStorage("newMsg")
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "New test message", record.MessageContent, "Should not update existing record")
// Test case: Disabled storage
config.WhatsappChatStorage = false
err = RecordMessage("anotherMsg", "user@test.com", "Should not be stored")
assert.NoError(suite.T(), err)
config.WhatsappChatStorage = true // Re-enable for next tests
_, err = FindRecordFromStorage("anotherMsg")
assert.Error(suite.T(), err, "Message should not be found when storage is disabled")
// Test case: Write permission error - Alternative approach to avoid platform-specific issues
// Instead of creating an unwritable file, we'll temporarily set PathChatStorage to a non-existent directory
nonExistentPath := filepath.Join(suite.tempDir, "non-existent-dir", "test.csv")
origPath := config.PathChatStorage
config.PathChatStorage = nonExistentPath
err = RecordMessage("failMsg", "user@test.com", "Should fail to write")
assert.Error(suite.T(), err)
assert.Contains(suite.T(), err.Error(), "failed to open file for writing")
// Restore path
config.PathChatStorage = origPath
}
func TestChatStorageTestSuite(t *testing.T) {
suite.Run(t, new(ChatStorageTestSuite))
}

185
src/pkg/utils/environment_test.go

@ -0,0 +1,185 @@
package utils_test
import (
"os"
"testing"
"time"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
type EnvironmentTestSuite struct {
suite.Suite
}
func (suite *EnvironmentTestSuite) SetupTest() {
// Clear any existing viper configs
viper.Reset()
// Set up automatic environment variable reading
viper.AutomaticEnv()
}
func (suite *EnvironmentTestSuite) TearDownTest() {
viper.Reset()
}
func (suite *EnvironmentTestSuite) TestIsLocal() {
tests := []struct {
name string
envValue string
expected bool
}{
{"Production environment", "production", false},
{"Staging environment", "staging", false},
{"Integration environment", "integration", false},
{"Development environment", "development", true},
{"Local environment", "local", true},
}
for _, tt := range tests {
suite.T().Run(tt.name, func(t *testing.T) {
// Set the environment value
if tt.envValue != "" {
viper.Set("APP_ENV", tt.envValue)
} else {
viper.Set("APP_ENV", nil) // Explicitly clear the value
}
result := utils.IsLocal()
assert.Equal(t, tt.expected, result)
})
}
}
func (suite *EnvironmentTestSuite) TestEnv() {
// Test with existing value
viper.Set("TEST_KEY", "test_value")
result := utils.Env[string]("TEST_KEY")
assert.Equal(suite.T(), "test_value", result)
// Test with default value
result = utils.Env("NON_EXISTENT_KEY", "default_value")
assert.Equal(suite.T(), "default_value", result)
// Test with integer
viper.Set("TEST_INT", 42)
intResult := utils.Env[int]("TEST_INT")
assert.Equal(suite.T(), 42, intResult)
// Test with default integer
intResult = utils.Env("NON_EXISTENT_INT", 100)
assert.Equal(suite.T(), 100, intResult)
// Test with boolean
viper.Set("TEST_BOOL", true)
boolResult := utils.Env[bool]("TEST_BOOL")
assert.Equal(suite.T(), true, boolResult)
}
func (suite *EnvironmentTestSuite) TestMustHaveEnv() {
// Test with value present
viper.Set("REQUIRED_ENV", "required_value")
result := utils.MustHaveEnv("REQUIRED_ENV")
assert.Equal(suite.T(), "required_value", result)
// Create a temporary .env file for testing
tempEnvContent := []byte("ENV_FROM_FILE=env_file_value\n")
err := os.WriteFile(".env", tempEnvContent, 0644)
assert.NoError(suite.T(), err)
defer os.Remove(".env")
// Test reading from .env file
result = utils.MustHaveEnv("ENV_FROM_FILE")
assert.Equal(suite.T(), "env_file_value", result)
// We can't easily test the fatal log scenario in a unit test
// as it would terminate the program
}
func (suite *EnvironmentTestSuite) TestMustHaveEnvBool() {
// Test true value
viper.Set("BOOL_TRUE", "true")
result := utils.MustHaveEnvBool("BOOL_TRUE")
assert.True(suite.T(), result)
// Test false value
viper.Set("BOOL_FALSE", "false")
result = utils.MustHaveEnvBool("BOOL_FALSE")
assert.False(suite.T(), result)
}
func (suite *EnvironmentTestSuite) TestMustHaveEnvInt() {
// Test valid integer
viper.Set("INT_VALUE", "42")
result := utils.MustHaveEnvInt("INT_VALUE")
assert.Equal(suite.T(), 42, result)
// Test zero
viper.Set("ZERO_INT", "0")
result = utils.MustHaveEnvInt("ZERO_INT")
assert.Equal(suite.T(), 0, result)
// Test negative number
viper.Set("NEG_INT", "-10")
result = utils.MustHaveEnvInt("NEG_INT")
assert.Equal(suite.T(), -10, result)
// We can't easily test the fatal log scenario with invalid int
// as it would terminate the program
}
func (suite *EnvironmentTestSuite) TestMustHaveEnvMinuteDuration() {
// Test valid duration
viper.Set("DURATION_MIN", "5")
result := utils.MustHaveEnvMinuteDuration("DURATION_MIN")
assert.Equal(suite.T(), 5*time.Minute, result)
// Test zero duration
viper.Set("ZERO_DURATION", "0")
result = utils.MustHaveEnvMinuteDuration("ZERO_DURATION")
assert.Equal(suite.T(), 0*time.Minute, result)
// We can't easily test the fatal log scenario with invalid duration
// as it would terminate the program
}
func (suite *EnvironmentTestSuite) TestLoadConfig() {
// Create a temporary config file for testing
tempDir, err := os.MkdirTemp("", "config_test")
assert.NoError(suite.T(), err)
defer os.RemoveAll(tempDir)
// Create test config file
configContent := []byte("TEST_CONFIG=config_value\n")
configPath := tempDir + "/.env"
err = os.WriteFile(configPath, configContent, 0644)
assert.NoError(suite.T(), err)
// Test loading config with default name
err = utils.LoadConfig(tempDir)
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "config_value", viper.GetString("TEST_CONFIG"))
// Test loading config with custom name
customConfigContent := []byte("CUSTOM_CONFIG=custom_value\n")
customConfigPath := tempDir + "/custom.env"
err = os.WriteFile(customConfigPath, customConfigContent, 0644)
assert.NoError(suite.T(), err)
viper.Reset()
err = utils.LoadConfig(tempDir, "custom")
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), "custom_value", viper.GetString("CUSTOM_CONFIG"))
// Test error case - non-existent directory
viper.Reset()
err = utils.LoadConfig("/non/existent/directory")
assert.Error(suite.T(), err)
}
func TestEnvironmentTestSuite(t *testing.T) {
suite.Run(t, new(EnvironmentTestSuite))
}

138
src/pkg/utils/general.go

@ -1,10 +1,15 @@
package utils
import (
"bytes"
"fmt"
"image"
_ "image/gif" // Register GIF format
_ "image/jpeg" // For JPEG encoding
_ "image/png" // For PNG encoding
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
@ -14,6 +19,8 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
"github.com/sirupsen/logrus"
_ "golang.org/x/image/webp" // Register WebP format
)
// RemoveFile is removing file with delay
@ -73,14 +80,35 @@ type Metadata struct {
Width *uint32
}
func GetMetaDataFromURL(url string) (meta Metadata, err error) {
func GetMetaDataFromURL(urlStr string) (meta Metadata, err error) {
// Create HTTP client with timeout
client := &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
// Parse the base URL for resolving relative URLs later
baseURL, err := url.Parse(urlStr)
if err != nil {
return meta, fmt.Errorf("invalid URL: %v", err)
}
// Send an HTTP GET request to the website
response, err := http.Get(url)
response, err := client.Get(urlStr)
if err != nil {
return meta, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return meta, fmt.Errorf("HTTP request failed with status: %s", response.Status)
}
// Parse the HTML document
document, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
@ -91,43 +119,95 @@ func GetMetaDataFromURL(url string) (meta Metadata, err error) {
meta.Description, _ = element.Attr("content")
})
// find title
document.Find("title").Each(func(index int, element *goquery.Selection) {
meta.Title = element.Text()
// find title - try multiple sources
// First try og:title
document.Find("meta[property='og:title']").Each(func(index int, element *goquery.Selection) {
if content, exists := element.Attr("content"); exists && content != "" {
meta.Title = content
}
})
// If og:title not found, try regular title tag
if meta.Title == "" {
document.Find("title").Each(func(index int, element *goquery.Selection) {
meta.Title = element.Text()
})
}
// Try to find image URL from various sources
// First try og:image
document.Find("meta[property='og:image']").Each(func(index int, element *goquery.Selection) {
meta.Image, _ = element.Attr("content")
})
document.Find("meta[property='og:image:width']").Each(func(index int, element *goquery.Selection) {
if content, exists := element.Attr("content"); exists {
width, _ := strconv.Atoi(content)
widthUint32 := uint32(width)
meta.Width = &widthUint32
if content, exists := element.Attr("content"); exists && content != "" {
meta.Image = content
}
})
document.Find("meta[property='og:image:height']").Each(func(index int, element *goquery.Selection) {
if content, exists := element.Attr("content"); exists {
height, _ := strconv.Atoi(content)
heightUint32 := uint32(height)
meta.Height = &heightUint32
}
})
// If og:image not found, try twitter:image
if meta.Image == "" {
document.Find("meta[name='twitter:image']").Each(func(index int, element *goquery.Selection) {
if content, exists := element.Attr("content"); exists && content != "" {
meta.Image = content
}
})
}
// If an og:image is found, download it and store its content in ImageThumb
// If an image URL is found, resolve it if it's relative
if meta.Image != "" {
imageResponse, err := http.Get(meta.Image)
imgURL, err := url.Parse(meta.Image)
if err != nil {
log.Printf("Failed to download image: %v", err)
logrus.Warnf("Invalid image URL: %v", err)
} else {
defer imageResponse.Body.Close()
imageData, err := io.ReadAll(imageResponse.Body)
if err != nil {
log.Printf("Failed to read image data: %v", err)
// Resolve relative URLs against the base URL
meta.Image = baseURL.ResolveReference(imgURL).String()
}
// Download the image
imgResponse, err := client.Get(meta.Image)
if err != nil {
logrus.Warnf("Failed to download image: %v", err)
} else {
defer imgResponse.Body.Close()
if imgResponse.StatusCode != http.StatusOK {
logrus.Warnf("Image download failed with status: %s", imgResponse.Status)
} else {
meta.ImageThumb = imageData
// Check content type
contentType := imgResponse.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
logrus.Warnf("URL returned non-image content type: %s", contentType)
} else {
// Read image data with size limit
imageData, err := io.ReadAll(io.LimitReader(imgResponse.Body, int64(config.WhatsappSettingMaxImageSize)))
if err != nil {
logrus.Warnf("Failed to read image data: %v", err)
} else if len(imageData) == 0 {
logrus.Warn("Downloaded image data is empty")
} else {
meta.ImageThumb = imageData
// Validate image by decoding it
imageReader := bytes.NewReader(imageData)
img, _, err := image.Decode(imageReader)
if err != nil {
logrus.Warnf("Failed to decode image: %v", err)
} else {
bounds := img.Bounds()
width := uint32(bounds.Max.X - bounds.Min.X)
height := uint32(bounds.Max.Y - bounds.Min.Y)
// Check if image is square (1:1 ratio)
if width == height && width <= 200 {
// For small square images, leave width and height as nil
meta.Width = nil
meta.Height = nil
} else {
meta.Width = &width
meta.Height = &height
}
logrus.Debugf("Image dimensions: %dx%d", width, height)
}
}
}
}
}
}

66
src/services/group.go

@ -2,6 +2,8 @@ package services
import (
"context"
"fmt"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
domainGroup "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/group"
pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
@ -113,6 +115,70 @@ func (service groupService) ManageParticipant(ctx context.Context, request domai
return result, nil
}
func (service groupService) GetGroupRequestParticipants(ctx context.Context, request domainGroup.GetGroupRequestParticipantsRequest) (result []domainGroup.GetGroupRequestParticipantsResponse, err error) {
if err = validations.ValidateGetGroupRequestParticipants(ctx, request); err != nil {
return result, err
}
groupJID, err := whatsapp.ValidateJidWithLogin(service.WaCli, request.GroupID)
if err != nil {
return result, err
}
participants, err := service.WaCli.GetGroupRequestParticipants(groupJID)
if err != nil {
return result, err
}
for _, participant := range participants {
result = append(result, domainGroup.GetGroupRequestParticipantsResponse{
JID: participant.JID.String(),
RequestedAt: participant.RequestedAt,
})
}
return result, nil
}
func (service groupService) ManageGroupRequestParticipants(ctx context.Context, request domainGroup.GroupRequestParticipantsRequest) (result []domainGroup.ParticipantStatus, err error) {
if err = validations.ValidateManageGroupRequestParticipants(ctx, request); err != nil {
return result, err
}
groupJID, err := whatsapp.ValidateJidWithLogin(service.WaCli, request.GroupID)
if err != nil {
return result, err
}
participantsJID, err := service.participantToJID(request.Participants)
if err != nil {
return result, err
}
participants, err := service.WaCli.UpdateGroupRequestParticipants(groupJID, participantsJID, request.Action)
if err != nil {
return result, err
}
for _, participant := range participants {
if participant.Error != 0 {
result = append(result, domainGroup.ParticipantStatus{
Participant: participant.JID.String(),
Status: "error",
Message: fmt.Sprintf("Action %s failed (code %d)", request.Action, participant.Error),
})
} else {
result = append(result, domainGroup.ParticipantStatus{
Participant: participant.JID.String(),
Status: "success",
Message: fmt.Sprintf("Action %s success", request.Action),
})
}
}
return result, nil
}
func (service groupService) participantToJID(participants []string) ([]types.JID, error) {
var participantsJID []types.JID
for _, participant := range participants {

3
src/services/message.go

@ -5,7 +5,6 @@ import (
"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"
"github.com/aldinokemal/go-whatsapp-web-multidevice/validations"
@ -55,7 +54,7 @@ func (service serviceMessage) MarkAsRead(ctx context.Context, request domainMess
return response, nil
}
func (service serviceMessage) ReactMessage(ctx context.Context, request message.ReactionRequest) (response message.GenericResponse, err error) {
func (service serviceMessage) ReactMessage(ctx context.Context, request domainMessage.ReactionRequest) (response domainMessage.GenericResponse, err error) {
if err = validations.ValidateReactMessage(ctx, request); err != nil {
return response, err
}

108
src/services/send.go

@ -59,18 +59,23 @@ func (service serviceSend) SendText(ctx context.Context, request domainSend.Mess
return response, err
}
// Send message
// Create base message
msg := &waE2E.Message{
ExtendedTextMessage: &waE2E.ExtendedTextMessage{
Text: proto.String(request.Message),
Text: proto.String(request.Message),
ContextInfo: &waE2E.ContextInfo{},
},
}
// Add forwarding context if IsForwarded is true
if request.IsForwarded {
msg.ExtendedTextMessage.ContextInfo.IsForwarded = proto.Bool(true)
msg.ExtendedTextMessage.ContextInfo.ForwardingScore = proto.Uint32(100)
}
parsedMentions := service.getMentionFromText(ctx, request.Message)
if len(parsedMentions) > 0 {
msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{
MentionedJID: parsedMentions,
}
msg.ExtendedTextMessage.ContextInfo.MentionedJID = parsedMentions
}
// Reply message
@ -207,6 +212,13 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima
ViewOnce: proto.Bool(request.ViewOnce),
}}
if request.IsForwarded {
msg.ImageMessage.ContextInfo = &waE2E.ContextInfo{
IsForwarded: proto.Bool(true),
ForwardingScore: proto.Uint32(100),
}
}
caption := "🖼️ Image"
if request.Caption != "" {
caption = "🖼️ " + request.Caption
@ -259,6 +271,14 @@ func (service serviceSend) SendFile(ctx context.Context, request domainSend.File
DirectPath: proto.String(uploadedFile.DirectPath),
Caption: proto.String(request.Caption),
}}
if request.IsForwarded {
msg.DocumentMessage.ContextInfo = &waE2E.ContextInfo{
IsForwarded: proto.Bool(true),
ForwardingScore: proto.Uint32(100),
}
}
caption := "📄 Document"
if request.Caption != "" {
caption = "📄 " + request.Caption
@ -371,6 +391,14 @@ func (service serviceSend) SendVideo(ctx context.Context, request domainSend.Vid
ThumbnailSHA256: dataWaThumbnail,
ThumbnailDirectPath: proto.String(uploaded.DirectPath),
}}
if request.IsForwarded {
msg.VideoMessage.ContextInfo = &waE2E.ContextInfo{
IsForwarded: proto.Bool(true),
ForwardingScore: proto.Uint32(100),
}
}
caption := "🎥 Video"
if request.Caption != "" {
caption = "🎥 " + request.Caption
@ -408,6 +436,13 @@ func (service serviceSend) SendContact(ctx context.Context, request domainSend.C
Vcard: proto.String(msgVCard),
}}
if request.IsForwarded {
msg.ContactMessage.ContextInfo = &waE2E.ContextInfo{
IsForwarded: proto.Bool(true),
ForwardingScore: proto.Uint32(100),
}
}
content := "👤 " + request.ContactName
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content)
@ -430,21 +465,50 @@ func (service serviceSend) SendLink(ctx context.Context, request domainSend.Link
return response, err
}
getMetaDataFromURL, err := utils.GetMetaDataFromURL(request.Link)
metadata, err := utils.GetMetaDataFromURL(request.Link)
if err != nil {
return response, err
}
// Log image dimensions if available, otherwise note it's a square image or dimensions not available
if metadata.Width != nil && metadata.Height != nil {
logrus.Debugf("Image dimensions: %dx%d", *metadata.Width, *metadata.Height)
} else {
logrus.Debugf("Image dimensions: Square image or dimensions not available")
}
// Create the message
msg := &waE2E.Message{ExtendedTextMessage: &waE2E.ExtendedTextMessage{
Text: proto.String(fmt.Sprintf("%s\n%s", request.Caption, request.Link)),
Title: proto.String(getMetaDataFromURL.Title),
MatchedText: proto.String(request.Link),
Description: proto.String(getMetaDataFromURL.Description),
JPEGThumbnail: getMetaDataFromURL.ImageThumb,
ThumbnailHeight: getMetaDataFromURL.Height,
ThumbnailWidth: getMetaDataFromURL.Width,
Text: proto.String(fmt.Sprintf("%s\n%s", request.Caption, request.Link)),
Title: proto.String(metadata.Title),
MatchedText: proto.String(request.Link),
Description: proto.String(metadata.Description),
JPEGThumbnail: metadata.ImageThumb,
}}
if request.IsForwarded {
msg.ExtendedTextMessage.ContextInfo = &waE2E.ContextInfo{
IsForwarded: proto.Bool(true),
ForwardingScore: proto.Uint32(100),
}
}
// If we have a thumbnail image, upload it to WhatsApp's servers
if len(metadata.ImageThumb) > 0 && metadata.Height != nil && metadata.Width != nil {
uploadedThumb, err := service.uploadMedia(ctx, whatsmeow.MediaLinkThumbnail, metadata.ImageThumb, dataWaRecipient)
if err == nil {
// Update the message with the uploaded thumbnail information
msg.ExtendedTextMessage.ThumbnailDirectPath = proto.String(uploadedThumb.DirectPath)
msg.ExtendedTextMessage.ThumbnailSHA256 = uploadedThumb.FileSHA256
msg.ExtendedTextMessage.ThumbnailEncSHA256 = uploadedThumb.FileEncSHA256
msg.ExtendedTextMessage.MediaKey = uploadedThumb.MediaKey
msg.ExtendedTextMessage.ThumbnailHeight = metadata.Height
msg.ExtendedTextMessage.ThumbnailWidth = metadata.Width
} else {
logrus.Warnf("Failed to upload thumbnail: %v, continue without uploaded thumbnail", err)
}
}
content := "🔗 " + request.Link
if request.Caption != "" {
content = "🔗 " + request.Caption
@ -477,6 +541,13 @@ func (service serviceSend) SendLocation(ctx context.Context, request domainSend.
},
}
if request.IsForwarded {
msg.LocationMessage.ContextInfo = &waE2E.ContextInfo{
IsForwarded: proto.Bool(true),
ForwardingScore: proto.Uint32(100),
}
}
content := "📍 " + request.Latitude + ", " + request.Longitude
// Send WhatsApp Message Proto
@ -521,6 +592,13 @@ func (service serviceSend) SendAudio(ctx context.Context, request domainSend.Aud
},
}
if request.IsForwarded {
msg.AudioMessage.ContextInfo = &waE2E.ContextInfo{
IsForwarded: proto.Bool(true),
ForwardingScore: proto.Uint32(100),
}
}
content := "🎵 Audio"
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content)
@ -545,7 +623,9 @@ func (service serviceSend) SendPoll(ctx context.Context, request domainSend.Poll
content := "📊 " + request.Question
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, service.WaCli.BuildPollCreation(request.Question, request.Options, request.MaxAnswer), content)
msg := service.WaCli.BuildPollCreation(request.Question, request.Options, request.MaxAnswer)
ts, err := service.wrapSendMessage(ctx, dataWaRecipient, msg, content)
if err != nil {
return response, err
}

11
src/services/user.go

@ -14,6 +14,7 @@ import (
"github.com/aldinokemal/go-whatsapp-web-multidevice/validations"
"github.com/disintegration/imaging"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types"
)
@ -231,3 +232,13 @@ func (service userService) ChangeAvatar(ctx context.Context, request domainUser.
return nil
}
func (service userService) ChangePushName(ctx context.Context, request domainUser.ChangePushNameRequest) (err error) {
whatsapp.MustLogin(service.WaCli)
err = service.WaCli.SendAppState(appstate.BuildSettingPushName(request.PushName))
if err != nil {
return err
}
return nil
}

29
src/validations/group_validation.go

@ -2,9 +2,11 @@ package validations
import (
"context"
domainGroup "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/group"
pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
validation "github.com/go-ozzo/ozzo-validation/v4"
"go.mau.fi/whatsmeow"
)
func ValidateJoinGroupWithLink(ctx context.Context, request domainGroup.JoinGroupWithLinkRequest) error {
@ -58,3 +60,30 @@ func ValidateParticipant(ctx context.Context, request domainGroup.ParticipantReq
return nil
}
func ValidateGetGroupRequestParticipants(ctx context.Context, request domainGroup.GetGroupRequestParticipantsRequest) error {
err := validation.ValidateStructWithContext(ctx, &request,
validation.Field(&request.GroupID, validation.Required),
)
if err != nil {
return pkgError.ValidationError(err.Error())
}
return nil
}
func ValidateManageGroupRequestParticipants(ctx context.Context, request domainGroup.GroupRequestParticipantsRequest) error {
err := validation.ValidateStructWithContext(ctx, &request,
validation.Field(&request.GroupID, validation.Required),
validation.Field(&request.Participants, validation.Required),
validation.Field(&request.Participants, validation.Each(validation.Required)),
validation.Field(&request.Action, validation.Required, validation.In(whatsmeow.ParticipantChangeApprove, whatsmeow.ParticipantChangeReject)),
)
if err != nil {
return pkgError.ValidationError(err.Error())
}
return nil
}

4
src/views/assets/app.css

@ -2,7 +2,7 @@
--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 */
--background-color: #FCF5EB; /* WhatsApp's authentic background */
--card-hover-color: #ffffff;
--text-color: #111B21; /* WhatsApp's text color */
--gradient-start: #00A884;
@ -204,7 +204,7 @@ body {
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;
/* background: var(--primary-color) !important; */
color: white !important;
font-weight: 600 !important;
letter-spacing: 0.5px;

96
src/views/components/AccountChangePushName.js

@ -0,0 +1,96 @@
export default {
name: 'AccountChangePushName',
data() {
return {
loading: false,
push_name: ''
}
},
methods: {
openModal() {
$('#modalChangePushName').modal({
onApprove: function () {
return false;
}
}).modal('show');
},
isValidForm() {
return this.push_name.trim() !== '';
},
async handleSubmit() {
if (!this.isValidForm() || this.loading) {
return;
}
try {
let response = await this.submitApi()
showSuccessInfo(response)
$('#modalChangePushName').modal('hide');
} catch (err) {
showErrorInfo(err)
}
},
async submitApi() {
this.loading = true;
try {
let payload = {
push_name: this.push_name
}
let response = await window.http.post(`/user/pushname`, 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.push_name = '';
}
},
template: `
<div class="olive card" @click="openModal()" style="cursor:pointer;">
<div class="content">
<a class="ui olive right ribbon label">Account</a>
<div class="header">Change Push Name</div>
<div class="description">
Update your WhatsApp display name
</div>
</div>
</div>
<!-- Modal Change Push Name -->
<div class="ui small modal" id="modalChangePushName">
<i class="close icon"></i>
<div class="header">
Change Push Name
</div>
<div class="content" style="max-height: 70vh; overflow-y: auto;">
<div class="ui info message">
<i class="info circle icon"></i>
Your push name is the display name shown to others in WhatsApp.
</div>
<form class="ui form">
<div class="field">
<label>New Push Name</label>
<input type="text" v-model="push_name" placeholder="Enter your new display name">
</div>
</form>
</div>
<div class="actions">
<button class="ui approve positive right labeled icon button"
:class="{'loading': this.loading, 'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Update Push Name
<i class="save icon"></i>
</button>
</div>
</div>
`
}

35
src/views/components/AccountContact.js

@ -39,6 +39,38 @@ export default {
},
getPhoneNumber(jid) {
return jid.split('@')[0];
},
exportToCSV() {
if (!this.contacts || this.contacts.length === 0) {
showErrorInfo("No contacts to export");
return;
}
// Create CSV content with headers
let csvContent = "Phone Number,Name\n";
// Add each contact as a row
this.contacts.forEach(contact => {
const phoneNumber = this.getPhoneNumber(contact.jid);
// Escape commas and quotes in the name field
const escapedName = contact.name ? contact.name.replace(/"/g, '""') : "";
csvContent += `${phoneNumber},"${escapedName}"\n`;
});
// Create a Blob with the CSV data
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
// Create a download link and trigger download
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', 'contacts.csv');
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showSuccessInfo("Contacts exported to CSV");
}
},
template: `
@ -57,6 +89,9 @@ export default {
<i class="close icon"></i>
<div class="header">
My Contacts
<button class="ui green right floated button" @click="exportToCSV">
<i class="download icon"></i> Export to CSV
</button>
</div>
<div class="content">
<table class="ui celled table" id="account_contacts_table">

136
src/views/components/GroupList.js

@ -1,8 +1,20 @@
export default {
name: 'ListGroup',
props: ['connected'],
data() {
return {
groups: []
groups: [],
selectedGroupId: null,
requestedMembers: [],
loadingRequestedMembers: false,
processingMember: null
}
},
computed: {
currentUserId() {
if (!this.connected || this.connected.length === 0) return null;
const device = this.connected[0].device;
return device.split('@')[0].split(':')[0];
}
},
methods: {
@ -67,6 +79,69 @@ export default {
formatDate: function (value) {
if (!value) return ''
return moment(value).format('LLL');
},
isAdmin(ownerJID) {
const owner = ownerJID.split('@')[0];
return owner === this.currentUserId;
},
async handleSeeRequestedMember(group_id) {
this.selectedGroupId = group_id;
this.loadingRequestedMembers = true;
this.requestedMembers = [];
try {
const response = await window.http.get(`/group/participant-requests?group_id=${group_id}`);
this.requestedMembers = response.data.results || [];
this.loadingRequestedMembers = false;
$('#modalRequestedMembers').modal('show');
} catch (error) {
this.loadingRequestedMembers = false;
let errorMessage = "Failed to fetch requested members";
if (error.response) {
errorMessage = error.response.data.message || errorMessage;
}
showErrorInfo(errorMessage);
}
},
formatJID(jid) {
return jid ? jid.split('@')[0] : '';
},
closeRequestedMembersModal() {
$('#modalRequestedMembers').modal('hide');
// open modal again
this.openModal();
},
async handleProcessRequest(member, action) {
if (!this.selectedGroupId || !member) return;
const actionText = action === 'approve' ? 'approve' : 'reject';
const confirmMsg = `Are you sure you want to ${actionText} this member request?`;
const ok = confirm(confirmMsg);
if (!ok) return;
try {
this.processingMember = member.jid;
const payload = {
group_id: this.selectedGroupId,
participants: [this.formatJID(member.jid)]
};
await window.http.post(`/group/participant-requests/${action}`, payload);
// Remove the processed member from the list
this.requestedMembers = this.requestedMembers.filter(m => m.jid !== member.jid);
showSuccessInfo(`Member request ${actionText}d`);
this.processingMember = null;
} catch (error) {
this.processingMember = null;
let errorMessage = `Failed to ${actionText} member request`;
if (error.response) {
errorMessage = error.response.data.message || errorMessage;
}
showErrorInfo(errorMessage);
}
}
},
template: `
@ -81,7 +156,7 @@ export default {
</div>
<!-- Modal AccountGroup -->
<div class="ui small modal" id="modalGroupList">
<div class="ui large modal" id="modalGroupList">
<i class="close icon"></i>
<div class="header">
My Group List
@ -104,12 +179,67 @@ export default {
<td>{{ g.Participants.length }}</td>
<td>{{ formatDate(g.GroupCreated) }}</td>
<td>
<button class="ui red tiny button" @click="handleLeaveGroup(g.JID)">Leave</button>
<div style="display: flex; gap: 8px; align-items: center;">
<button v-if="isAdmin(g.OwnerJID)" class="ui green tiny button" @click="handleSeeRequestedMember(g.JID)">Requested Members</button>
<button class="ui red tiny button" @click="handleLeaveGroup(g.JID)">Leave</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Requested Members Modal -->
<div class="ui modal" id="modalRequestedMembers">
<i class="close icon"></i>
<div class="header">
Requested Group Members
</div>
<div class="content">
<div v-if="loadingRequestedMembers" class="ui active centered inline loader"></div>
<div v-else-if="requestedMembers.length === 0" class="ui info message">
<div class="header">No Requested Members</div>
<p>There are no pending member requests for this group.</p>
</div>
<table v-else class="ui celled table">
<thead>
<tr>
<th>User ID</th>
<th>Request Time</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="member in requestedMembers" :key="member.jid">
<td>{{ formatJID(member.jid) }}</td>
<td>{{ formatDate(member.requested_at) }}</td>
<td>
<div class="ui mini buttons">
<button class="ui green button"
@click="handleProcessRequest(member, 'approve')"
:disabled="processingMember === member.jid">
<i v-if="processingMember === member.jid" class="spinner loading icon"></i>
Approve
</button>
<div class="or"></div>
<button class="ui red button"
@click="handleProcessRequest(member, 'reject')"
:disabled="processingMember === member.jid">
<i v-if="processingMember === member.jid" class="spinner loading icon"></i>
Reject
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="actions">
<div class="ui button" @click="closeRequestedMembersModal">Close</div>
</div>
</div>
`
}

14
src/views/components/SendAudio.js

@ -10,7 +10,8 @@ export default {
phone: '',
type: window.TYPEUSER,
loading: false,
selectedFileName: null
selectedFileName: null,
is_forwarded: false
}
},
computed: {
@ -27,7 +28,7 @@ export default {
}).modal('show');
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
if (this.type !== window.TYPEUSER && !this.phone.trim()) {
return false;
}
@ -55,6 +56,7 @@ export default {
try {
let payload = new FormData();
payload.append("phone", this.phone_id)
payload.append("is_forwarded", this.is_forwarded)
payload.append("audio", $("#file_audio")[0].files[0])
const response = await window.http.post(`/send/audio`, payload)
this.handleReset();
@ -71,6 +73,7 @@ export default {
handleReset() {
this.phone = '';
this.type = window.TYPEUSER;
this.is_forwarded = false;
$("#file_audio").val('');
this.selectedFileName = null;
},
@ -101,6 +104,13 @@ export default {
<div class="content">
<form class="ui form">
<FormRecipient v-model:type="type" v-model:phone="phone"/>
<div class="field">
<label>Is Forwarded</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="is forwarded" v-model="is_forwarded">
<label>Mark audio as forwarded</label>
</div>
</div>
<div class="field" style="padding-bottom: 30px">
<label>Audio</label>
<input type="file" style="display: none" accept="audio/*" id="file_audio" @change="handleFileChange"/>

20
src/views/components/SendContact.js

@ -12,6 +12,7 @@ export default {
card_name: '',
card_phone: '',
loading: false,
is_forwarded: false
}
},
computed: {
@ -27,6 +28,9 @@ export default {
}
}).modal('show');
},
isShowAttributes() {
return this.type !== window.TYPESTATUS;
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
@ -44,18 +48,15 @@ export default {
},
async handleSubmit() {
try {
this.loading = true;
let response = await this.submitApi()
showSuccessInfo(response)
$('#modalSendContact').modal('hide');
} catch (err) {
showErrorInfo(err)
} finally {
this.loading = false;
}
},
async submitApi() {
if (!this.isValidForm() || this.loading) {
if (!this.isValidForm()) {
return;
}
@ -64,7 +65,8 @@ export default {
const payload = {
phone: this.phone_id,
contact_name: this.card_name,
contact_phone: this.card_phone
contact_phone: this.card_phone,
is_forwarded: this.is_forwarded
}
let response = await window.http.post(`/send/contact`, payload)
this.handleReset();
@ -83,6 +85,7 @@ export default {
this.card_name = '';
this.card_phone = '';
this.type = window.TYPEUSER;
this.is_forwarded = false;
},
},
template: `
@ -116,6 +119,13 @@ export default {
<input v-model="card_phone" type="text" placeholder="Please enter contact phone"
aria-label="contact phone">
</div>
<div class="field" v-if="isShowAttributes()">
<label>Is Forwarded</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="is forwarded" v-model="is_forwarded">
<label>Mark contact as forwarded</label>
</div>
</div>
</form>
</div>
<div class="actions">

15
src/views/components/SendFile.js

@ -17,7 +17,8 @@ export default {
type: window.TYPEUSER,
phone: '',
loading: false,
selectedFileName: null
selectedFileName: null,
is_forwarded: false
}
},
computed: {
@ -33,6 +34,9 @@ export default {
}
}).modal('show');
},
isShowAttributes() {
return this.type !== window.TYPESTATUS;
},
isValidForm() {
if (this.type !== window.TYPESTATUS && !this.phone.trim()) {
return false;
@ -63,6 +67,7 @@ export default {
let payload = new FormData();
payload.append("caption", this.caption)
payload.append("phone", this.phone_id)
payload.append("is_forwarded", this.is_forwarded)
payload.append("file", $("#file_file")[0].files[0])
let response = await window.http.post(`/send/file`, payload)
this.handleReset();
@ -81,6 +86,7 @@ export default {
this.phone = '';
this.type = window.TYPEUSER;
this.selectedFileName = null;
this.is_forwarded = false;
$("#file_file").val('');
},
handleFileChange(event) {
@ -117,6 +123,13 @@ export default {
<textarea v-model="caption" placeholder="Type some caption (optional)..."
aria-label="caption"></textarea>
</div>
<div class="field" v-if="isShowAttributes()">
<label>Is Forwarded</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="is forwarded" v-model="is_forwarded">
<label>Mark file as forwarded</label>
</div>
</div>
<div class="field" style="padding-bottom: 30px">
<label>File</label>
<input type="file" style="display: none" id="file_file" @change="handleFileChange">

20
src/views/components/SendImage.js

@ -15,7 +15,8 @@ export default {
loading: false,
selected_file: null,
image_url: null,
preview_url: null
preview_url: null,
is_forwarded: false
}
},
computed: {
@ -23,6 +24,14 @@ export default {
return this.phone + this.type;
},
},
watch: {
view_once(newValue) {
// If view_once is set to true, set is_forwarded to false
if (newValue === true) {
this.is_forwarded = false;
}
}
},
methods: {
openModal() {
$('#modalSendImage').modal({
@ -66,6 +75,7 @@ export default {
payload.append("view_once", this.view_once)
payload.append("compress", this.compress)
payload.append("caption", this.caption)
payload.append("is_forwarded", this.is_forwarded)
const fileInput = $("#file_image");
if (fileInput.length > 0 && fileInput[0].files.length > 0) {
@ -96,6 +106,7 @@ export default {
this.preview_url = null;
this.selected_file = null;
this.image_url = null;
this.is_forwarded = false;
$("#file_image").val('');
},
handleImageChange(event) {
@ -155,6 +166,13 @@ export default {
<label>Check for compressing image to smaller size</label>
</div>
</div>
<div class="field" v-if="isShowAttributes() && !view_once">
<label>Is Forwarded</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="is forwarded" v-model="is_forwarded">
<label>Mark image as forwarded</label>
</div>
</div>
<div class="field">
<label>Image URL</label>
<input type="text" v-model="image_url" placeholder="https://example.com/image.jpg"

148
src/views/components/SendLink.js

@ -0,0 +1,148 @@
import FormRecipient from "./generic/FormRecipient.js";
export default {
name: 'SendLink',
components: {
FormRecipient
},
data() {
return {
type: window.TYPEUSER,
phone: '',
link: '',
caption: '',
reply_message_id: '',
loading: false,
is_forwarded: false
}
},
computed: {
phone_id() {
return this.phone + this.type;
},
},
methods: {
openModal() {
$('#modalSendLink').modal({
onApprove: function () {
return false;
}
}).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 link is not empty and has reasonable length
const isLinkValid = this.link.trim().length > 0 && this.link.length <= 4096;
// Validate caption is not empty and has reasonable length
const isCaptionValid = this.caption.trim().length > 0 && this.caption.length <= 4096;
return isPhoneValid && isLinkValid && isCaptionValid
},
async handleSubmit() {
// Add validation check here to prevent submission when form is invalid
if (!this.isValidForm() || this.loading) {
return;
}
try {
const response = await this.submitApi();
showSuccessInfo(response);
$('#modalSendLink').modal('hide');
} catch (err) {
showErrorInfo(err);
}
},
async submitApi() {
this.loading = true;
try {
const payload = {
phone: this.phone_id,
link: this.link.trim(),
caption: this.caption.trim(),
is_forwarded: this.is_forwarded
};
if (this.reply_message_id !== '') {
payload.reply_message_id = this.reply_message_id;
}
const response = await window.http.post('/send/link', payload);
this.handleReset();
return response.data.message;
} catch (error) {
if (error.response?.data?.message) {
throw new Error(error.response.data.message);
}
throw error;
} finally {
this.loading = false;
}
},
handleReset() {
this.phone = '';
this.link = '';
this.caption = '';
this.reply_message_id = '';
this.is_forwarded = false;
},
},
template: `
<div class="blue card" @click="openModal()" style="cursor: pointer">
<div class="content">
<a class="ui blue right ribbon label">Send</a>
<div class="header">Send Link</div>
<div class="description">
Send link to user or group
</div>
</div>
</div>
<!-- Modal SendLink -->
<div class="ui small modal" id="modalSendLink">
<i class="close icon"></i>
<div class="header">
Send Link
</div>
<div class="content">
<form class="ui form">
<FormRecipient v-model:type="type" v-model:phone="phone" :show-status="true"/>
<div class="field" v-if="isShowReplyId()">
<label>Reply Message ID</label>
<input v-model="reply_message_id" type="text"
placeholder="Optional: 57D29F74B7FC62F57D8AC2C840279B5B/3EB0288F008D32FCD0A424"
aria-label="reply_message_id">
</div>
<div class="field">
<label>Link</label>
<input v-model="link" type="text" placeholder="https://www.google.com"
aria-label="link">
</div>
<div class="field">
<label>Caption</label>
<textarea v-model="caption" placeholder="Hello this is caption"
aria-label="caption"></textarea>
</div>
<div class="field" v-if="isShowReplyId()">
<label>Is Forwarded</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="is forwarded" v-model="is_forwarded">
<label>Mark link as forwarded</label>
</div>
</div>
</form>
</div>
<div class="actions">
<button class="ui approve positive right labeled icon button"
:class="{'disabled': !isValidForm() || loading}"
@click.prevent="handleSubmit">
Send
<i class="send icon"></i>
</button>
</div>
</div>
`
}

15
src/views/components/SendLocation.js

@ -12,6 +12,7 @@ export default {
latitude: '',
longitude: '',
loading: false,
is_forwarded: false
}
},
computed: {
@ -43,6 +44,9 @@ export default {
}
}).modal('show');
},
isShowAttributes() {
return this.type !== window.TYPESTATUS;
},
async handleSubmit() {
try {
let response = await this.submitApi()
@ -58,7 +62,8 @@ export default {
const payload = {
phone: this.phone_id,
latitude: this.latitude,
longitude: this.longitude
longitude: this.longitude,
is_forwarded: this.is_forwarded
};
const response = await window.http.post(`/send/location`, payload);
@ -78,6 +83,7 @@ export default {
this.latitude = '';
this.longitude = '';
this.type = window.TYPEUSER;
this.is_forwarded = false;
},
},
template: `
@ -111,6 +117,13 @@ export default {
<input v-model="longitude" type="text" placeholder="Please enter longitude (-180 to 180)"
aria-label="longitude">
</div>
<div class="field" v-if="isShowAttributes()">
<label>Is Forwarded</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="is forwarded" v-model="is_forwarded">
<label>Mark location as forwarded</label>
</div>
</div>
</form>
</div>
<div class="actions">

10
src/views/components/SendMessage.js

@ -11,6 +11,7 @@ export default {
phone: '',
text: '',
reply_message_id: '',
is_forwarded: false,
loading: false,
}
},
@ -58,6 +59,7 @@ export default {
const payload = {
phone: this.phone_id,
message: this.text.trim(),
is_forwarded: this.is_forwarded
};
if (this.reply_message_id !== '') {
payload.reply_message_id = this.reply_message_id;
@ -79,6 +81,7 @@ export default {
this.phone = '';
this.text = '';
this.reply_message_id = '';
this.is_forwarded = false;
},
},
template: `
@ -112,6 +115,13 @@ export default {
<textarea v-model="text" placeholder="Hello this is message text"
aria-label="message"></textarea>
</div>
<div class="field" v-if="isShowReplyId()">
<label>Is Forwarded</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="is forwarded" v-model="is_forwarded">
<label>Mark message as forwarded</label>
</div>
</div>
</form>
</div>
<div class="actions">

18
src/views/components/SendVideo.js

@ -20,6 +20,7 @@ export default {
phone: '',
loading: false,
selectedFileName: null,
is_forwarded: false
}
},
computed: {
@ -27,6 +28,14 @@ export default {
return this.phone + this.type;
},
},
watch: {
view_once(newValue) {
// If view_once is set to true, set is_forwarded to false
if (newValue === true) {
this.is_forwarded = false;
}
}
},
methods: {
openModal() {
$('#modalSendVideo').modal({
@ -81,6 +90,7 @@ export default {
payload.append("caption", this.caption.trim())
payload.append("view_once", this.view_once)
payload.append("compress", this.compress)
payload.append("is_forwarded", this.is_forwarded)
payload.append('video', $("#file_video")[0].files[0])
let response = await window.http.post(`/send/video`, payload)
this.handleReset();
@ -100,6 +110,7 @@ export default {
this.compress = false;
this.phone = '';
this.selectedFileName = null;
this.is_forwarded = false;
$("#file_video").val('');
},
handleFileChange(event) {
@ -152,6 +163,13 @@ export default {
<label>Check for compressing video to smaller size</label>
</div>
</div>
<div class="field" v-if="isShowAttributes() && !view_once">
<label>Is Forwarded</label>
<div class="ui toggle checkbox">
<input type="checkbox" aria-label="is forwarded" v-model="is_forwarded">
<label>Mark video as forwarded</label>
</div>
</div>
<div class="field" style="padding-bottom: 30px">
<label>Video</label>
<input type="file" style="display: none" accept="video/*" id="file_video" @change="handleFileChange">

13
src/views/index.html

@ -116,6 +116,8 @@
<send-audio></send-audio>
<send-poll></send-poll>
<send-presence></send-presence>
<send-link></send-link>
</div>
<div class="ui horizontal divider">
@ -134,7 +136,7 @@
</div>
<div class="ui three column doubling grid cards">
<group-list></group-list>
<group-list :connected="connected_devices"></group-list>
<group-create></group-create>
<group-join-with-link></group-join-with-link>
<group-add-participants></group-add-participants>
@ -155,6 +157,7 @@
<div class="ui three column doubling grid cards">
<account-avatar></account-avatar>
<account-change-avatar></account-change-avatar>
<account-change-push-name></account-change-push-name>
<account-user-info></account-user-info>
<account-privacy></account-privacy>
<account-contact></account-contact>
@ -201,6 +204,7 @@
import SendImage from "./components/SendImage.js";
import SendFile from "./components/SendFile.js";
import SendVideo from "./components/SendVideo.js";
import SendLink from "./components/SendLink.js";
import SendContact from "./components/SendContact.js";
import SendLocation from "./components/SendLocation.js";
import SendAudio from "./components/SendAudio.js";
@ -214,11 +218,12 @@
import GroupCreate from "./components/GroupCreate.js";
import GroupJoinWithLink from "./components/GroupJoinWithLink.js";
import GroupAddParticipants from "./components/GroupManageParticipants.js";
import NewsletterList from "./components/NewsletterList.js";
import AccountAvatar from "./components/AccountAvatar.js";
import AccountChangeAvatar from "./components/AccountChangeAvatar.js";
import AccountChangePushName from "./components/AccountChangePushName.js";
import AccountUserInfo from "./components/AccountUserInfo.js";
import AccountPrivacy from "./components/AccountPrivacy.js";
import NewsletterList from "./components/NewsletterList.js";
import AccountContact from "./components/AccountContact.js";
const showErrorInfo = (message) => {
@ -254,11 +259,11 @@
Vue.createApp({
components: {
AppLogin, AppLoginWithCode, AppLogout, AppReconnect,
SendMessage, SendImage, SendFile, SendVideo, SendContact, SendLocation, SendAudio, SendPoll, SendPresence,
SendMessage, SendImage, SendFile, SendVideo, SendLink, SendContact, SendLocation, SendAudio, SendPoll, SendPresence,
MessageDelete, MessageUpdate, MessageReact, MessageRevoke,
GroupList, GroupCreate, GroupJoinWithLink, GroupAddParticipants,
NewsletterList,
AccountAvatar, AccountUserInfo, AccountPrivacy, AccountChangeAvatar, AccountContact
AccountAvatar, AccountUserInfo, AccountPrivacy, AccountChangeAvatar, AccountContact, AccountChangePushName
},
delimiters: ['[[', ']]'],
data() {

Loading…
Cancel
Save