├── static
├── favicon.ico
├── images
│ ├── favicon.png
│ ├── qr_donate.png
│ ├── background_image.png
│ └── wave-pattern.svg
├── api
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ └── index.html
├── login
│ └── styles.css
├── js
│ └── main.js
├── github-markdown-css
│ ├── code-navigation-banner-illo.svg
│ └── github-css.css
└── index.html
├── .github
├── FUNDING.yml
└── workflows
│ ├── contributors.yml
│ ├── build.yml
│ └── docker-publish.yml
├── wuzapi.service
├── .dockerignore
├── .gitignore
├── .env.sample
├── LICENSE
├── Dockerfile
├── constants.go
├── clients.go
├── docker-compose.yml
├── docker-compose-swarm.yaml
├── go.mod
├── db.go
├── routes.go
├── rabbitmq.go
├── s3manager.go
├── main.go
├── stdio.go
├── go.sum
└── migrations.go
/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asternic/wuzapi/HEAD/static/favicon.ico
--------------------------------------------------------------------------------
/static/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asternic/wuzapi/HEAD/static/images/favicon.png
--------------------------------------------------------------------------------
/static/api/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asternic/wuzapi/HEAD/static/api/favicon-16x16.png
--------------------------------------------------------------------------------
/static/api/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asternic/wuzapi/HEAD/static/api/favicon-32x32.png
--------------------------------------------------------------------------------
/static/images/qr_donate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asternic/wuzapi/HEAD/static/images/qr_donate.png
--------------------------------------------------------------------------------
/static/images/background_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asternic/wuzapi/HEAD/static/images/background_image.png
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [asternic]
4 | custom: ['https://www.paypal.com/donate/?hosted_button_id=ZV46M9NKE9M8L']
5 |
--------------------------------------------------------------------------------
/static/images/wave-pattern.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/wuzapi.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Wuzapi
3 | After=network-online.target
4 | Wants=network-online.target systemd-networkd-wait-online.service
5 |
6 | StartLimitIntervalSec=500
7 | StartLimitBurst=5
8 |
9 | [Service]
10 | Restart=on-failure
11 | RestartSec=5s
12 | WorkingDirectory=/usr/local/wuzapi
13 | ExecStart=/usr/local/wuzapi/wuzapi -wadebug DEBUG
14 |
15 | [Install]
16 | WantedBy=multi-user.target
17 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | .gitignore
3 | .github
4 |
5 | # Development files
6 | .env
7 | .env.*
8 | *.log
9 | .tool-versions
10 |
11 | # Data directories (will be mounted as volumes)
12 | dbdata/
13 | files/
14 |
15 | # Build artifacts and binaries
16 | wuzapi
17 | *.exe
18 | *.o
19 | *.out
20 |
21 | # Documentation
22 | README.md
23 | API.md
24 | LICENSE
25 | *.md
26 |
27 | # Others
28 | .DS_Store
29 | .idea/
30 | .vscode/
31 | *.swp
32 | *.swo
33 | tmp/
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dbdata/
2 | files/
3 | wuzapi
4 | .env
5 | .tool-versions
6 |
7 | # Added by Claude Task Master
8 | # Logs
9 | logs
10 | *.log
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | dev-debug.log
15 | # Dependency directories
16 | node_modules/
17 | # Environment variables
18 | # Editor directories and files
19 | .idea
20 | .vscode
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 | # OS specific
27 | .DS_Store
28 |
29 | # Task files
30 | tasks.json
31 | tasks/
32 | scripts/
33 | .roo/
34 | .cursor/
35 | .roomodes
36 | .taskmasterconfig
37 | .windsurfrules
38 | NEW_FEATS.md
39 |
40 | *.code-workspace
41 | **/*.code-workspace
42 | .vscode/workspaceStorage/
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | # .env
2 | # Server Configuration
3 | WUZAPI_PORT=8080
4 | WUZAPI_ADDRESS=0.0.0.0
5 |
6 | # Token for WuzAPI Admin
7 | WUZAPI_ADMIN_TOKEN=1234ABCD
8 |
9 | # Encryption key for sensitive data (32 bytes for AES-256)
10 | WUZAPI_GLOBAL_ENCRYPTION_KEY=your_32_byte_encryption_key_here
11 |
12 | # Global HMAC Key for webhook signing (minimum 32 characters)
13 | WUZAPI_GLOBAL_HMAC_KEY=your_global_hmac_key_here_minimum_32_chars
14 |
15 | # Global webhook URL
16 | WUZAPI_GLOBAL_WEBHOOK=https://example.com/webhook
17 |
18 | # "json" or "form" for the default
19 | WEBHOOK_FORMAT=json
20 |
21 | # WuzAPI Session Configuration
22 | SESSION_DEVICE_NAME=WuzAPI
23 |
24 | # Database configuration
25 | DB_USER=wuzapi
26 | DB_PASSWORD=wuzapi
27 | DB_NAME=wuzapi
28 | DB_HOST=db
29 | DB_PORT=5432
30 | DB_SSLMODE=false
31 | TZ=America/Sao_Paulo
32 |
33 | # RabbitMQ configuration Optional
34 | RABBITMQ_URL=amqp://wuzapi:wuzapi@localhost:5672/%2F
35 | RABBITMQ_QUEUE=whatsapp_events
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Nicolas Gudiño and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/contributors.yml:
--------------------------------------------------------------------------------
1 | name: Update Contributors
2 | on:
3 | schedule:
4 | - cron: "0 0 * * *" # Runs daily
5 | workflow_dispatch: # Allows manual triggering
6 | jobs:
7 | update-contributors:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: BobAnkh/add-contributors@master
12 | with:
13 | CONTRIBUTOR: '## Contributors'
14 | COLUMN_PER_ROW: '6'
15 | ACCESS_TOKEN: ${{secrets.GITHUB_TOKEN}}
16 | IMG_WIDTH: '100'
17 | FONT_SIZE: '14'
18 | PATH: '/README.md'
19 | COMMIT_MESSAGE: 'docs(README): update contributors'
20 | AVATAR_SHAPE: 'round'
21 | - name: Check for changes
22 | id: changes
23 | run: |
24 | if git diff --quiet; then
25 | echo "has_changes=false" >> $GITHUB_OUTPUT
26 | else
27 | echo "has_changes=true" >> $GITHUB_OUTPUT
28 | fi
29 | - name: Commit changes
30 | if: steps.changes.outputs.has_changes == 'true'
31 | run: |
32 | git config --global user.name 'GitHub Actions'
33 | git config --global user.email 'actions@github.com'
34 | git commit -am "$COMMIT_MESSAGE"
35 | git push
36 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build and Test
2 |
3 | on:
4 | push:
5 | branches: [ main, develop ]
6 | pull_request:
7 | branches: [ main, develop ]
8 | workflow_dispatch:
9 |
10 | jobs:
11 | build:
12 | name: Build Go Application
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v4
18 |
19 | - name: Set up Go
20 | uses: actions/setup-go@v5
21 | with:
22 | go-version: '1.24.0'
23 | cache: true
24 |
25 | - name: Download dependencies
26 | run: go mod download
27 |
28 | - name: Verify dependencies
29 | run: go mod verify
30 |
31 | - name: Tidy dependencies
32 | run: go mod tidy
33 |
34 | - name: Check for uncommitted changes
35 | run: |
36 | if [[ -n $(git status --porcelain) ]]; then
37 | echo "Error: go mod tidy produced changes. Please run 'go mod tidy' locally and commit the changes."
38 | git diff
39 | exit 1
40 | fi
41 |
42 | - name: Run go vet
43 | run: go vet ./...
44 |
45 | - name: Build application
46 | run: go build -v -o wuzapi
47 |
48 | - name: Upload build artifact
49 | if: success()
50 | uses: actions/upload-artifact@v4
51 | with:
52 | name: wuzapi-binary
53 | path: wuzapi
54 | retention-days: 7
55 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.24-bullseye AS builder
2 |
3 | RUN apt-get update && apt-get install -y --no-install-recommends \
4 | ca-certificates \
5 | && apt-get clean \
6 | && rm -rf /var/lib/apt/lists/*
7 |
8 | # Install build dependencies
9 | RUN apt-get update && apt-get install -y --no-install-recommends \
10 | gcc \
11 | g++ \
12 | pkg-config \
13 | && apt-get clean \
14 | && rm -rf /var/lib/apt/lists/*
15 |
16 | WORKDIR /app
17 | COPY go.mod go.sum ./
18 | RUN go mod download
19 |
20 | COPY . .
21 | ENV CGO_ENABLED=1
22 | RUN go build -o wuzapi
23 |
24 | FROM debian:bullseye-slim
25 |
26 | RUN apt-get update && apt-get install -y --no-install-recommends \
27 | ca-certificates \
28 | && apt-get clean \
29 | && rm -rf /var/lib/apt/lists/*
30 |
31 | # Install runtime dependencies
32 | RUN apt-get update && apt-get install -y --no-install-recommends \
33 | ca-certificates \
34 | netcat-openbsd \
35 | postgresql-client \
36 | openssl \
37 | curl \
38 | ffmpeg \
39 | tzdata \
40 | && rm -rf /var/lib/apt/lists/*
41 |
42 | ENV TZ="America/Sao_Paulo"
43 | WORKDIR /app
44 |
45 | COPY --from=builder /app/wuzapi /app/
46 | COPY --from=builder /app/static /app/static/
47 | COPY --from=builder /app/wuzapi.service /app/wuzapi.service
48 |
49 | RUN chmod +x /app/wuzapi && \
50 | chmod -R 755 /app && \
51 | chown -R root:root /app
52 |
53 | ENTRYPOINT ["/app/wuzapi", "--logtype=console", "--color=true"]
54 |
--------------------------------------------------------------------------------
/static/api/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | WUZAPI API
7 |
8 |
14 |
20 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/.github/workflows/docker-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 | types: [closed]
11 |
12 | jobs:
13 | build-and-push:
14 | runs-on: ubuntu-latest
15 | environment: DOCKER
16 | if: github.event_name != 'pull_request' || github.event.pull_request.merged == true
17 |
18 | steps:
19 | - name: Check out the repo
20 | uses: actions/checkout@v4
21 |
22 | - name: Set up QEMU
23 | uses: docker/setup-qemu-action@v3
24 |
25 | - name: Set up Docker Buildx
26 | uses: docker/setup-buildx-action@v3
27 | with:
28 | driver-opts: |
29 | image=moby/buildkit:buildx-stable-1
30 |
31 | - name: Login to Docker Hub
32 | uses: docker/login-action@v3
33 | with:
34 | username: ${{ secrets.DOCKERHUB_USERNAME }}
35 | password: ${{ secrets.DOCKERHUB_TOKEN }}
36 |
37 | - name: Extract metadata (tags, labels) for Docker
38 | id: meta
39 | uses: docker/metadata-action@v5
40 | with:
41 | images: asternic/wuzapi
42 | tags: |
43 | type=raw,value=latest
44 | type=sha,format=short
45 |
46 | - name: Build and push Docker image
47 | uses: docker/build-push-action@v5
48 | with:
49 | context: .
50 | platforms: linux/amd64,linux/arm64
51 | push: true
52 | tags: ${{ steps.meta.outputs.tags }}
53 | labels: ${{ steps.meta.outputs.labels }}
54 | cache-from: type=gha
55 | cache-to: type=gha,mode=max
56 |
--------------------------------------------------------------------------------
/constants.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | // List of supported event types
4 | var supportedEventTypes = []string{
5 | // Messages and Communication
6 | "Message",
7 | "UndecryptableMessage",
8 | "Receipt",
9 | "MediaRetry",
10 | "ReadReceipt",
11 |
12 | // Groups and Contacts
13 | "GroupInfo",
14 | "JoinedGroup",
15 | "Picture",
16 | "BlocklistChange",
17 | "Blocklist",
18 |
19 | // Connection and Session
20 | "Connected",
21 | "Disconnected",
22 | "ConnectFailure",
23 | "KeepAliveRestored",
24 | "KeepAliveTimeout",
25 | "QRTimeout",
26 | "LoggedOut",
27 | "ClientOutdated",
28 | "TemporaryBan",
29 | "StreamError",
30 | "StreamReplaced",
31 | "PairSuccess",
32 | "PairError",
33 | "QR",
34 | "QRScannedWithoutMultidevice",
35 |
36 | // Privacy and Settings
37 | "PrivacySettings",
38 | "PushNameSetting",
39 | "UserAbout",
40 |
41 | // Synchronization and State
42 | "AppState",
43 | "AppStateSyncComplete",
44 | "HistorySync",
45 | "OfflineSyncCompleted",
46 | "OfflineSyncPreview",
47 |
48 | // Calls
49 | "CallOffer",
50 | "CallAccept",
51 | "CallTerminate",
52 | "CallOfferNotice",
53 | "CallRelayLatency",
54 |
55 | // Presence and Activity
56 | "Presence",
57 | "ChatPresence",
58 |
59 | // Identity
60 | "IdentityChange",
61 |
62 | // Erros
63 | "CATRefreshError",
64 |
65 | // Newsletter (WhatsApp Channels)
66 | "NewsletterJoin",
67 | "NewsletterLeave",
68 | "NewsletterMuteChange",
69 | "NewsletterLiveUpdate",
70 |
71 | // Facebook/Meta Bridge
72 | "FBMessage",
73 |
74 | // Special - receives all events
75 | "All",
76 | }
77 |
78 | // Map for quick validation
79 | var eventTypeMap map[string]bool
80 |
81 | func init() {
82 | eventTypeMap = make(map[string]bool)
83 | for _, eventType := range supportedEventTypes {
84 | eventTypeMap[eventType] = true
85 | }
86 | }
87 |
88 | // Auxiliary function to validate event type
89 | func isValidEventType(eventType string) bool {
90 | return eventTypeMap[eventType]
91 | }
92 |
--------------------------------------------------------------------------------
/clients.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/go-resty/resty/v2"
7 | "go.mau.fi/whatsmeow"
8 | )
9 |
10 | type ClientManager struct {
11 | sync.RWMutex
12 | whatsmeowClients map[string]*whatsmeow.Client
13 | httpClients map[string]*resty.Client
14 | myClients map[string]*MyClient
15 | }
16 |
17 | func NewClientManager() *ClientManager {
18 | return &ClientManager{
19 | whatsmeowClients: make(map[string]*whatsmeow.Client),
20 | httpClients: make(map[string]*resty.Client),
21 | myClients: make(map[string]*MyClient),
22 | }
23 | }
24 |
25 | func (cm *ClientManager) SetWhatsmeowClient(userID string, client *whatsmeow.Client) {
26 | cm.Lock()
27 | defer cm.Unlock()
28 | cm.whatsmeowClients[userID] = client
29 | }
30 |
31 | func (cm *ClientManager) GetWhatsmeowClient(userID string) *whatsmeow.Client {
32 | cm.RLock()
33 | defer cm.RUnlock()
34 | return cm.whatsmeowClients[userID]
35 | }
36 |
37 | func (cm *ClientManager) DeleteWhatsmeowClient(userID string) {
38 | cm.Lock()
39 | defer cm.Unlock()
40 | delete(cm.whatsmeowClients, userID)
41 | }
42 |
43 | func (cm *ClientManager) SetHTTPClient(userID string, client *resty.Client) {
44 | cm.Lock()
45 | defer cm.Unlock()
46 | cm.httpClients[userID] = client
47 | }
48 |
49 | func (cm *ClientManager) GetHTTPClient(userID string) *resty.Client {
50 | cm.RLock()
51 | defer cm.RUnlock()
52 | return cm.httpClients[userID]
53 | }
54 |
55 | func (cm *ClientManager) DeleteHTTPClient(userID string) {
56 | cm.Lock()
57 | defer cm.Unlock()
58 | delete(cm.httpClients, userID)
59 | }
60 |
61 | func (cm *ClientManager) SetMyClient(userID string, client *MyClient) {
62 | cm.Lock()
63 | defer cm.Unlock()
64 | cm.myClients[userID] = client
65 | }
66 |
67 | func (cm *ClientManager) GetMyClient(userID string) *MyClient {
68 | cm.RLock()
69 | defer cm.RUnlock()
70 | return cm.myClients[userID]
71 | }
72 |
73 | func (cm *ClientManager) DeleteMyClient(userID string) {
74 | cm.Lock()
75 | defer cm.Unlock()
76 | delete(cm.myClients, userID)
77 | }
78 |
79 | // UpdateMyClientSubscriptions updates the event subscriptions of a client without reconnecting
80 | func (cm *ClientManager) UpdateMyClientSubscriptions(userID string, subscriptions []string) {
81 | cm.Lock()
82 | defer cm.Unlock()
83 | if client, exists := cm.myClients[userID]; exists {
84 | client.subscriptions = subscriptions
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | wuzapi-server:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile
6 | ports:
7 | - "${WUZAPI_PORT:-8080}:8080"
8 | environment:
9 | - WUZAPI_ADMIN_TOKEN=${WUZAPI_ADMIN_TOKEN}
10 | - WUZAPI_GLOBAL_ENCRYPTION_KEY=${WUZAPI_GLOBAL_ENCRYPTION_KEY}
11 | - WUZAPI_GLOBAL_HMAC_KEY=${WUZAPI_GLOBAL_HMAC_KEY:-}
12 | - WUZAPI_GLOBAL_WEBHOOK=${WUZAPI_GLOBAL_WEBHOOK:-}
13 | - DB_USER=${DB_USER:-wuzapi}
14 | - DB_PASSWORD=${DB_PASSWORD:-wuzapi}
15 | - DB_NAME=${DB_NAME:-wuzapi}
16 | - DB_HOST=db
17 | - DB_PORT=${DB_PORT:-5432}
18 | - TZ=${TZ:-America/Sao_Paulo}
19 | - WEBHOOK_FORMAT=${WEBHOOK_FORMAT:-json}
20 | - SESSION_DEVICE_NAME=${SESSION_DEVICE_NAME:-WuzAPI}
21 | # RabbitMQ configuration Optional
22 | - RABBITMQ_URL=amqp://wuzapi:wuzapi@rabbitmq:5672/
23 | - RABBITMQ_QUEUE=whatsapp_events
24 | depends_on:
25 | db:
26 | condition: service_healthy
27 | rabbitmq:
28 | condition: service_healthy
29 | networks:
30 | - wuzapi-network
31 | restart: on-failure
32 |
33 | db:
34 | image: postgres:16
35 | environment:
36 | POSTGRES_USER: ${DB_USER:-wuzapi}
37 | POSTGRES_PASSWORD: ${DB_PASSWORD:-wuzapi}
38 | POSTGRES_DB: ${DB_NAME:-wuzapi}
39 | # ports:
40 | # - "${DB_PORT:-5432}:5432" # Uncomment to access the database directly from your host machine.
41 | volumes:
42 | - db_data:/var/lib/postgresql/data
43 | networks:
44 | - wuzapi-network
45 | healthcheck:
46 | test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-wuzapi}"]
47 | interval: 5s
48 | timeout: 5s
49 | retries: 5
50 | restart: always
51 |
52 | rabbitmq:
53 | image: rabbitmq:3-management
54 | hostname: rabbitmq
55 | environment:
56 | RABBITMQ_DEFAULT_USER: wuzapi
57 | RABBITMQ_DEFAULT_PASS: wuzapi
58 | RABBITMQ_DEFAULT_VHOST: /
59 | ports:
60 | - "5672:5672" # AMQP port
61 | - "15672:15672" # Management UI port
62 | volumes:
63 | - rabbitmq_data:/var/lib/rabbitmq
64 | networks:
65 | - wuzapi-network
66 | healthcheck:
67 | test: ["CMD", "rabbitmq-diagnostics", "ping"]
68 | interval: 10s
69 | timeout: 5s
70 | retries: 5
71 | restart: always
72 |
73 | networks:
74 | wuzapi-network:
75 | driver: bridge
76 |
77 | volumes:
78 | db_data:
79 | rabbitmq_data:
80 |
--------------------------------------------------------------------------------
/docker-compose-swarm.yaml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | wuzapi-server:
5 | image: asternic/wuzapi:latest
6 | networks:
7 | - network_public
8 | environment:
9 | - WUZAPI_ADMIN_TOKEN=${WUZAPI_ADMIN_TOKEN}
10 | - DB_USER=${DB_USER:-wuzapi}
11 | - DB_PASSWORD=${DB_PASSWORD:-wuzapi}
12 | - DB_NAME=${DB_NAME:-wuzapi}
13 | - DB_HOST=db
14 |
15 | - DB_PORT=${DB_PORT:-5432}
16 | - TZ=${TZ:-America/Sao_Paulo}
17 | - WEBHOOK_FORMAT=${WEBHOOK_FORMAT:-json}
18 | - SESSION_DEVICE_NAME=${SESSION_DEVICE_NAME:-WuzAPI}
19 | # RabbitMQ configuration Optional
20 | - RABBITMQ_URL=amqp://wuzapi:wuzapi@rabbitmq:5672/
21 | - RABBITMQ_QUEUE=whatsapp_events
22 | deploy:
23 | mode: replicated
24 | replicas: 1
25 | restart_policy:
26 | condition: on-failure
27 | placement:
28 | constraints: [node.role == manager]
29 | resources:
30 | limits:
31 | cpus: "1"
32 | memory: 512MB
33 | labels:
34 | - traefik.enable=true
35 | - traefik.http.routers.wuzapi-server.rule=Host(`api.wuzapi.app`)
36 | - traefik.http.routers.wuzapi-server.entrypoints=websecure
37 | - traefik.http.routers.wuzapi-server.priority=1
38 | - traefik.http.routers.wuzapi-server.tls.certresolver=letsencryptresolver
39 | - traefik.http.routers.wuzapi-server.service=wuzapi-server
40 | - traefik.http.services.wuzapi-server.loadbalancer.server.port=${WUZAPI_PORT:-8080}
41 | # healthcheck:
42 | # test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
43 | # interval: 10s
44 | # timeout: 5s
45 | # retries: 3
46 | # start_period: 10s
47 |
48 | # RabbitMQ service OPTIONAL
49 | rabbitmq:
50 | image: rabbitmq:3-management
51 | hostname: rabbitmq
52 | networks:
53 | - network_public
54 | environment:
55 | - RABBITMQ_DEFAULT_USER=wuzapi
56 | - RABBITMQ_DEFAULT_PASS=wuzapi
57 | - RABBITMQ_DEFAULT_VHOST=/
58 | deploy:
59 | mode: replicated
60 | replicas: 1
61 | restart_policy:
62 | condition: on-failure
63 | placement:
64 | constraints: [node.role == manager]
65 | resources:
66 | limits:
67 | cpus: "0.5"
68 | memory: 256MB
69 | labels:
70 | - traefik.enable=true
71 | - traefik.http.routers.rabbitmq-management.rule=Host(`rabbitmq.wuzapi.app`)
72 | - traefik.http.routers.rabbitmq-management.entrypoints=websecure
73 | - traefik.http.routers.rabbitmq-management.priority=1
74 | - traefik.http.routers.rabbitmq-management.tls.certresolver=letsencryptresolver
75 | - traefik.http.routers.rabbitmq-management.service=rabbitmq-management
76 | - traefik.http.services.rabbitmq-management.loadbalancer.server.port=15672
77 | volumes:
78 | - rabbitmq_data:/var/lib/rabbitmq
79 |
80 | volumes:
81 | rabbitmq_data:
82 |
83 | networks:
84 | network_public:
85 | name: network_public
86 | external: true
87 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module wuzapi
2 |
3 | go 1.24.0
4 |
5 | toolchain go1.24.7
6 |
7 | require (
8 | github.com/aws/aws-sdk-go-v2 v1.36.3
9 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67
10 | github.com/aws/aws-sdk-go-v2/service/s3 v1.79.4
11 | github.com/go-resty/resty/v2 v2.16.5
12 | github.com/gorilla/mux v1.8.1
13 | github.com/mdp/qrterminal/v3 v3.2.1
14 | github.com/patrickmn/go-cache v2.1.0+incompatible
15 | github.com/rs/zerolog v1.34.0
16 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
17 | go.mau.fi/whatsmeow v0.0.0-20251120135021-071293c6b9f0
18 | google.golang.org/protobuf v1.36.10
19 | )
20 |
21 | require (
22 | github.com/PuerkitoBio/goquery v1.10.3
23 | github.com/justinas/alice v1.2.0
24 | github.com/lib/pq v1.10.9
25 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
26 | github.com/rabbitmq/amqp091-go v1.10.0
27 | github.com/vincent-petithory/dataurl v1.0.0
28 | golang.org/x/image v0.32.0
29 | golang.org/x/sync v0.18.0
30 | modernc.org/sqlite v1.37.1
31 | )
32 |
33 | require (
34 | github.com/andybalholm/cascadia v1.3.3 // indirect
35 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
36 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
37 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
38 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
39 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
40 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 // indirect
41 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
42 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
43 | github.com/aws/smithy-go v1.22.3 // indirect
44 | github.com/beeper/argo-go v1.1.2 // indirect
45 | github.com/coder/websocket v1.8.14 // indirect
46 | github.com/dustin/go-humanize v1.0.1 // indirect
47 | github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
48 | github.com/ncruces/go-strftime v0.1.9 // indirect
49 | github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 // indirect
50 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
51 | github.com/rs/xid v1.6.0 // indirect
52 | github.com/vektah/gqlparser/v2 v2.5.31 // indirect
53 | golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
54 | golang.org/x/term v0.37.0 // indirect
55 | golang.org/x/text v0.31.0 // indirect
56 | modernc.org/libc v1.65.8 // indirect
57 | modernc.org/mathutil v1.7.1 // indirect
58 | modernc.org/memory v1.11.0 // indirect
59 | )
60 |
61 | require (
62 | filippo.io/edwards25519 v1.1.0 // indirect
63 | github.com/google/uuid v1.6.0 // indirect
64 | github.com/jmoiron/sqlx v1.4.0
65 | github.com/joho/godotenv v1.5.1
66 | github.com/mattn/go-colorable v0.1.14 // indirect
67 | github.com/mattn/go-isatty v0.0.20 // indirect
68 | go.mau.fi/libsignal v0.2.1 // indirect
69 | go.mau.fi/util v0.9.3 // indirect
70 | golang.org/x/crypto v0.45.0 // indirect
71 | golang.org/x/net v0.47.0
72 | golang.org/x/sys v0.38.0 // indirect
73 | rsc.io/qr v0.2.0 // indirect
74 | )
75 |
--------------------------------------------------------------------------------
/db.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path/filepath"
7 | "time"
8 |
9 | "github.com/jmoiron/sqlx"
10 | _ "github.com/lib/pq"
11 | _ "modernc.org/sqlite"
12 | )
13 |
14 | type DatabaseConfig struct {
15 | Type string
16 | Host string
17 | Port string
18 | User string
19 | Password string
20 | Name string
21 | Path string
22 | SSLMode string
23 | }
24 |
25 | func InitializeDatabase(exPath, dataDirFlag string) (*sqlx.DB, error) {
26 | config := getDatabaseConfig(exPath, dataDirFlag)
27 |
28 | if config.Type == "postgres" {
29 | return initializePostgres(config)
30 | }
31 | return initializeSQLite(config)
32 | }
33 |
34 | func getDatabaseConfig(exPath, dataDirFlag string) DatabaseConfig {
35 | dbUser := os.Getenv("DB_USER")
36 | dbPassword := os.Getenv("DB_PASSWORD")
37 | dbName := os.Getenv("DB_NAME")
38 | dbHost := os.Getenv("DB_HOST")
39 | dbPort := os.Getenv("DB_PORT")
40 | dbSSL := os.Getenv("DB_SSLMODE")
41 |
42 | sslMode := dbSSL
43 | if dbSSL == "true" {
44 | sslMode = "require"
45 | } else if dbSSL == "false" || dbSSL == "" {
46 | sslMode = "disable"
47 | }
48 |
49 | if dbUser != "" && dbPassword != "" && dbName != "" && dbHost != "" && dbPort != "" {
50 | return DatabaseConfig{
51 | Type: "postgres",
52 | Host: dbHost,
53 | Port: dbPort,
54 | User: dbUser,
55 | Password: dbPassword,
56 | Name: dbName,
57 | SSLMode: sslMode,
58 | }
59 | }
60 |
61 | // Use datadir flag if provided, otherwise fall back to executable directory
62 | dataPath := exPath
63 | if dataDirFlag != "" {
64 | dataPath = dataDirFlag
65 | }
66 |
67 | return DatabaseConfig{
68 | Type: "sqlite",
69 | Path: filepath.Join(dataPath, "dbdata"),
70 | }
71 | }
72 |
73 | func initializePostgres(config DatabaseConfig) (*sqlx.DB, error) {
74 | dsn := fmt.Sprintf(
75 | "user=%s password=%s dbname=%s host=%s port=%s sslmode=%s",
76 | config.User, config.Password, config.Name, config.Host, config.Port, config.SSLMode,
77 | )
78 |
79 | db, err := sqlx.Open("postgres", dsn)
80 | if err != nil {
81 | return nil, fmt.Errorf("failed to open postgres connection: %w", err)
82 | }
83 |
84 | if err := db.Ping(); err != nil {
85 | return nil, fmt.Errorf("failed to ping postgres database: %w", err)
86 | }
87 |
88 | return db, nil
89 | }
90 |
91 | func initializeSQLite(config DatabaseConfig) (*sqlx.DB, error) {
92 | if err := os.MkdirAll(config.Path, 0751); err != nil {
93 | return nil, fmt.Errorf("could not create dbdata directory: %w", err)
94 | }
95 |
96 | dbPath := filepath.Join(config.Path, "users.db")
97 | db, err := sqlx.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_busy_timeout=3000")
98 | if err != nil {
99 | return nil, fmt.Errorf("failed to open sqlite database: %w", err)
100 | }
101 |
102 | if err := db.Ping(); err != nil {
103 | return nil, fmt.Errorf("failed to ping sqlite database: %w", err)
104 | }
105 |
106 | return db, nil
107 | }
108 |
109 | type HistoryMessage struct {
110 | ID int `json:"id" db:"id"`
111 | UserID string `json:"user_id" db:"user_id"`
112 | ChatJID string `json:"chat_jid" db:"chat_jid"`
113 | SenderJID string `json:"sender_jid" db:"sender_jid"`
114 | MessageID string `json:"message_id" db:"message_id"`
115 | Timestamp time.Time `json:"timestamp" db:"timestamp"`
116 | MessageType string `json:"message_type" db:"message_type"`
117 | TextContent string `json:"text_content" db:"text_content"`
118 | MediaLink string `json:"media_link" db:"media_link"`
119 | QuotedMessageID string `json:"quoted_message_id,omitempty" db:"quoted_message_id"`
120 | DataJson string `json:"data_json" db:"datajson"`
121 | }
122 |
123 | func (s *server) saveMessageToHistory(userID, chatJID, senderJID, messageID, messageType, textContent, mediaLink, quotedMessageID, dataJson string) error {
124 | query := `INSERT INTO message_history (user_id, chat_jid, sender_jid, message_id, timestamp, message_type, text_content, media_link, quoted_message_id, datajson)
125 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
126 | if s.db.DriverName() == "sqlite" {
127 | query = `INSERT INTO message_history (user_id, chat_jid, sender_jid, message_id, timestamp, message_type, text_content, media_link, quoted_message_id, datajson)
128 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
129 | }
130 | _, err := s.db.Exec(query, userID, chatJID, senderJID, messageID, time.Now(), messageType, textContent, mediaLink, quotedMessageID, dataJson)
131 | if err != nil {
132 | return fmt.Errorf("failed to save message to history: %w", err)
133 | }
134 | return nil
135 | }
136 |
137 | func (s *server) trimMessageHistory(userID, chatJID string, limit int) error {
138 | var queryHistory, querySecrets string
139 |
140 | if s.db.DriverName() == "postgres" {
141 | queryHistory = `
142 | DELETE FROM message_history
143 | WHERE id IN (
144 | SELECT id FROM message_history
145 | WHERE user_id = $1 AND chat_jid = $2
146 | ORDER BY timestamp DESC
147 | OFFSET $3
148 | )`
149 |
150 | querySecrets = `
151 | DELETE FROM whatsmeow_message_secrets
152 | WHERE message_id IN (
153 | SELECT id FROM message_history
154 | WHERE user_id = $1 AND chat_jid = $2
155 | ORDER BY timestamp DESC
156 | OFFSET $3
157 | )`
158 | } else { // sqlite
159 | queryHistory = `
160 | DELETE FROM message_history
161 | WHERE id IN (
162 | SELECT id FROM message_history
163 | WHERE user_id = ? AND chat_jid = ?
164 | ORDER BY timestamp DESC
165 | LIMIT -1 OFFSET ?
166 | )`
167 |
168 | querySecrets = `
169 | DELETE FROM whatsmeow_message_secrets
170 | WHERE message_id IN (
171 | SELECT id FROM message_history
172 | WHERE user_id = ? AND chat_jid = ?
173 | ORDER BY timestamp DESC
174 | LIMIT -1 OFFSET ?
175 | )`
176 | }
177 |
178 | if _, err := s.db.Exec(querySecrets, userID, chatJID, limit); err != nil {
179 | return fmt.Errorf("failed to trim message secrets: %w", err)
180 | }
181 |
182 | if _, err := s.db.Exec(queryHistory, userID, chatJID, limit); err != nil {
183 | return fmt.Errorf("failed to trim message history: %w", err)
184 | }
185 |
186 | return nil
187 | }
188 |
--------------------------------------------------------------------------------
/routes.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "net/http"
5 | "os"
6 | "path/filepath"
7 | "time"
8 |
9 | "github.com/justinas/alice"
10 | "github.com/rs/zerolog"
11 | "github.com/rs/zerolog/hlog"
12 | )
13 |
14 | type Middleware = alice.Constructor
15 |
16 | func (s *server) routes() {
17 |
18 | ex, err := os.Executable()
19 | if err != nil {
20 | panic(err)
21 | }
22 | exPath := filepath.Dir(ex)
23 |
24 | var routerLog zerolog.Logger
25 | logOutput := os.Stdout
26 | if s.mode == Stdio {
27 | logOutput = os.Stderr
28 | }
29 | if *logType == "json" {
30 | routerLog = zerolog.New(logOutput).
31 | With().
32 | Timestamp().
33 | Str("role", filepath.Base(os.Args[0])).
34 | Str("host", *address).
35 | Logger()
36 | } else {
37 | output := zerolog.ConsoleWriter{
38 | Out: logOutput,
39 | TimeFormat: time.RFC3339,
40 | NoColor: !*colorOutput,
41 | }
42 | routerLog = zerolog.New(output).
43 | With().
44 | Timestamp().
45 | Str("role", filepath.Base(os.Args[0])).
46 | Str("host", *address).
47 | Logger()
48 | }
49 |
50 | s.router.Handle("/health", s.GetHealth()).Methods("GET")
51 |
52 | adminRoutes := s.router.PathPrefix("/admin").Subrouter()
53 | adminRoutes.Use(s.authadmin)
54 | adminRoutes.Handle("/users", s.ListUsers()).Methods("GET")
55 | adminRoutes.Handle("/users/{id}", s.ListUsers()).Methods("GET")
56 | adminRoutes.Handle("/users", s.AddUser()).Methods("POST")
57 | adminRoutes.Handle("/users/{id}", s.EditUser()).Methods("PUT")
58 | adminRoutes.Handle("/users/{id}", s.DeleteUser()).Methods("DELETE")
59 | adminRoutes.Handle("/users/{id}/full", s.DeleteUserComplete()).Methods("DELETE")
60 |
61 | c := alice.New()
62 | c = c.Append(s.authalice)
63 | c = c.Append(hlog.NewHandler(routerLog))
64 |
65 | c = c.Append(hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) {
66 | hlog.FromRequest(r).Info().
67 | Str("method", r.Method).
68 | Stringer("url", r.URL).
69 | Int("status", status).
70 | Int("size", size).
71 | Dur("duration", duration).
72 | Str("userid", r.Context().Value("userinfo").(Values).Get("Id")).
73 | Msg("Got API Request")
74 | }))
75 |
76 | c = c.Append(hlog.RemoteAddrHandler("ip"))
77 | c = c.Append(hlog.UserAgentHandler("user_agent"))
78 | c = c.Append(hlog.RefererHandler("referer"))
79 | c = c.Append(hlog.RequestIDHandler("req_id", "Request-Id"))
80 |
81 | s.router.Handle("/session/connect", c.Then(s.Connect())).Methods("POST")
82 | s.router.Handle("/session/disconnect", c.Then(s.Disconnect())).Methods("POST")
83 | s.router.Handle("/session/logout", c.Then(s.Logout())).Methods("POST")
84 | s.router.Handle("/session/status", c.Then(s.GetStatus())).Methods("GET")
85 | s.router.Handle("/session/qr", c.Then(s.GetQR())).Methods("GET")
86 | s.router.Handle("/session/pairphone", c.Then(s.PairPhone())).Methods("POST")
87 | s.router.Handle("/session/history", c.Then(s.RequestHistorySync())).Methods("GET")
88 |
89 | s.router.Handle("/webhook", c.Then(s.SetWebhook())).Methods("POST")
90 | s.router.Handle("/webhook", c.Then(s.GetWebhook())).Methods("GET")
91 | s.router.Handle("/webhook", c.Then(s.DeleteWebhook())).Methods("DELETE")
92 | s.router.Handle("/webhook", c.Then(s.UpdateWebhook())).Methods("PUT")
93 |
94 | s.router.Handle("/session/proxy", c.Then(s.SetProxy())).Methods("POST")
95 | s.router.Handle("/session/history", c.Then(s.SetHistory())).Methods("POST")
96 |
97 | s.router.Handle("/session/s3/config", c.Then(s.ConfigureS3())).Methods("POST")
98 | s.router.Handle("/session/s3/config", c.Then(s.GetS3Config())).Methods("GET")
99 | s.router.Handle("/session/s3/config", c.Then(s.DeleteS3Config())).Methods("DELETE")
100 | s.router.Handle("/session/s3/test", c.Then(s.TestS3Connection())).Methods("POST")
101 |
102 | s.router.Handle("/session/hmac/config", c.Then(s.ConfigureHmac())).Methods("POST")
103 | s.router.Handle("/session/hmac/config", c.Then(s.GetHmacConfig())).Methods("GET")
104 | s.router.Handle("/session/hmac/config", c.Then(s.DeleteHmacConfig())).Methods("DELETE")
105 |
106 | s.router.Handle("/chat/send/text", c.Then(s.SendMessage())).Methods("POST")
107 | s.router.Handle("/chat/delete", c.Then(s.DeleteMessage())).Methods("POST")
108 | s.router.Handle("/chat/send/image", c.Then(s.SendImage())).Methods("POST")
109 | s.router.Handle("/chat/send/audio", c.Then(s.SendAudio())).Methods("POST")
110 | s.router.Handle("/chat/send/document", c.Then(s.SendDocument())).Methods("POST")
111 | // s.router.Handle("/chat/send/template", c.Then(s.SendTemplate())).Methods("POST")
112 | s.router.Handle("/chat/send/video", c.Then(s.SendVideo())).Methods("POST")
113 | s.router.Handle("/chat/send/sticker", c.Then(s.SendSticker())).Methods("POST")
114 | s.router.Handle("/chat/send/location", c.Then(s.SendLocation())).Methods("POST")
115 | s.router.Handle("/chat/send/contact", c.Then(s.SendContact())).Methods("POST")
116 | s.router.Handle("/chat/react", c.Then(s.React())).Methods("POST")
117 | s.router.Handle("/chat/send/buttons", c.Then(s.SendButtons())).Methods("POST")
118 | s.router.Handle("/chat/send/list", c.Then(s.SendList())).Methods("POST")
119 | s.router.Handle("/chat/send/poll", c.Then(s.SendPoll())).Methods("POST")
120 | s.router.Handle("/chat/send/edit", c.Then(s.SendEditMessage())).Methods("POST")
121 | s.router.Handle("/chat/history", c.Then(s.GetHistory())).Methods("GET")
122 | s.router.Handle("/chat/request-unavailable-message", c.Then(s.RequestUnavailableMessage())).Methods("POST")
123 | s.router.Handle("/chat/archive", c.Then(s.ArchiveChat())).Methods("POST")
124 |
125 | s.router.Handle("/status/set/text", c.Then(s.SetStatusMessage())).Methods("POST")
126 |
127 | s.router.Handle("/call/reject", c.Then(s.RejectCall())).Methods("POST")
128 |
129 | s.router.Handle("/user/presence", c.Then(s.SendPresence())).Methods("POST")
130 | s.router.Handle("/user/info", c.Then(s.GetUser())).Methods("POST")
131 | s.router.Handle("/user/check", c.Then(s.CheckUser())).Methods("POST")
132 | s.router.Handle("/user/avatar", c.Then(s.GetAvatar())).Methods("POST")
133 | s.router.Handle("/user/contacts", c.Then(s.GetContacts())).Methods("GET")
134 | s.router.Handle("/user/lid/{jid}", c.Then(s.GetUserLID())).Methods("GET")
135 |
136 | s.router.Handle("/chat/presence", c.Then(s.ChatPresence())).Methods("POST")
137 | s.router.Handle("/chat/markread", c.Then(s.MarkRead())).Methods("POST")
138 | s.router.Handle("/chat/downloadimage", c.Then(s.DownloadImage())).Methods("POST")
139 | s.router.Handle("/chat/downloadvideo", c.Then(s.DownloadVideo())).Methods("POST")
140 | s.router.Handle("/chat/downloadaudio", c.Then(s.DownloadAudio())).Methods("POST")
141 | s.router.Handle("/chat/downloaddocument", c.Then(s.DownloadDocument())).Methods("POST")
142 | s.router.Handle("/chat/downloadsticker", c.Then(s.DownloadSticker())).Methods("POST")
143 |
144 | s.router.Handle("/group/create", c.Then(s.CreateGroup())).Methods("POST")
145 | s.router.Handle("/group/list", c.Then(s.ListGroups())).Methods("GET")
146 | s.router.Handle("/group/info", c.Then(s.GetGroupInfo())).Methods("GET")
147 | s.router.Handle("/group/invitelink", c.Then(s.GetGroupInviteLink())).Methods("GET")
148 | s.router.Handle("/group/photo", c.Then(s.SetGroupPhoto())).Methods("POST")
149 | s.router.Handle("/group/photo/remove", c.Then(s.RemoveGroupPhoto())).Methods("POST")
150 | s.router.Handle("/group/leave", c.Then(s.GroupLeave())).Methods("POST")
151 | s.router.Handle("/group/name", c.Then(s.SetGroupName())).Methods("POST")
152 | s.router.Handle("/group/topic", c.Then(s.SetGroupTopic())).Methods("POST")
153 | s.router.Handle("/group/announce", c.Then(s.SetGroupAnnounce())).Methods("POST")
154 | s.router.Handle("/group/locked", c.Then(s.SetGroupLocked())).Methods("POST")
155 | s.router.Handle("/group/ephemeral", c.Then(s.SetDisappearingTimer())).Methods("POST")
156 | s.router.Handle("/group/join", c.Then(s.GroupJoin())).Methods("POST")
157 | s.router.Handle("/group/inviteinfo", c.Then(s.GetGroupInviteInfo())).Methods("POST")
158 | s.router.Handle("/group/updateparticipants", c.Then(s.UpdateGroupParticipants())).Methods("POST")
159 |
160 | s.router.Handle("/newsletter/list", c.Then(s.ListNewsletter())).Methods("GET")
161 |
162 | s.router.PathPrefix("/").Handler(http.FileServer(http.Dir(exPath + "/static/")))
163 | }
164 |
--------------------------------------------------------------------------------
/rabbitmq.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "os"
6 | "sync"
7 | "time"
8 |
9 | "github.com/rabbitmq/amqp091-go"
10 | "github.com/rs/zerolog/log"
11 | )
12 |
13 | var (
14 | rabbitConn *amqp091.Connection
15 | rabbitChannel *amqp091.Channel
16 | rabbitEnabled bool
17 | rabbitOnce sync.Once
18 | rabbitQueue string
19 | )
20 |
21 | const (
22 | maxRetries = 10
23 | retryInterval = 3 * time.Second
24 | )
25 |
26 | // Call this in main() or initialization
27 | func InitRabbitMQ() {
28 | rabbitURL := os.Getenv("RABBITMQ_URL")
29 | rabbitQueue = os.Getenv("RABBITMQ_QUEUE")
30 |
31 | if rabbitQueue == "" {
32 | rabbitQueue = "whatsapp_events" // default queue
33 | }
34 |
35 | if rabbitURL == "" {
36 | rabbitEnabled = false
37 | log.Info().Msg("RABBITMQ_URL is not set. RabbitMQ publishing disabled.")
38 | return
39 | }
40 |
41 | // Attempt to connect with retry
42 | for attempt := 1; attempt <= maxRetries; attempt++ {
43 | log.Info().
44 | Int("attempt", attempt).
45 | Int("max_retries", maxRetries).
46 | Msg("Attempting to connect to RabbitMQ")
47 |
48 | conn, err := amqp091.Dial(rabbitURL)
49 | if err != nil {
50 | log.Warn().
51 | Err(err).
52 | Int("attempt", attempt).
53 | Int("max_retries", maxRetries).
54 | Msg("Failed to connect to RabbitMQ")
55 |
56 | if attempt < maxRetries {
57 | log.Info().
58 | Dur("retry_in", retryInterval).
59 | Msg("Retrying RabbitMQ connection")
60 | time.Sleep(retryInterval)
61 | continue
62 | }
63 |
64 | // Last attempt failed
65 | rabbitEnabled = false
66 | log.Error().
67 | Err(err).
68 | Msg("Could not connect to RabbitMQ after all retries. RabbitMQ disabled.")
69 | return
70 | }
71 |
72 | // Connection successful, attempt to open channel
73 | channel, err := conn.Channel()
74 | if err != nil {
75 | conn.Close()
76 | log.Warn().
77 | Err(err).
78 | Int("attempt", attempt).
79 | Msg("Failed to open RabbitMQ channel")
80 |
81 | if attempt < maxRetries {
82 | log.Info().
83 | Dur("retry_in", retryInterval).
84 | Msg("Retrying RabbitMQ connection")
85 | time.Sleep(retryInterval)
86 | continue
87 | }
88 |
89 | // Last attempt failed
90 | rabbitEnabled = false
91 | log.Error().
92 | Err(err).
93 | Msg("Could not open RabbitMQ channel after all retries. RabbitMQ disabled.")
94 | return
95 | }
96 |
97 | // Success!
98 | rabbitConn = conn
99 | rabbitChannel = channel
100 | rabbitEnabled = true
101 |
102 | log.Info().
103 | Str("queue", rabbitQueue).
104 | Int("attempt", attempt).
105 | Msg("RabbitMQ connection established successfully")
106 |
107 | // Setup handler for automatic reconnection on errors
108 | go handleConnectionErrors()
109 | return
110 | }
111 | }
112 |
113 | // Monitor connection errors and attempt reconnection
114 | func handleConnectionErrors() {
115 | notifyClose := rabbitConn.NotifyClose(make(chan *amqp091.Error))
116 |
117 | for err := range notifyClose {
118 | log.Error().
119 | Err(err).
120 | Msg("RabbitMQ connection closed unexpectedly. Attempting reconnection...")
121 |
122 | rabbitEnabled = false
123 |
124 | // Attempt to reconnect
125 | for attempt := 1; attempt <= maxRetries; attempt++ {
126 | log.Info().
127 | Int("attempt", attempt).
128 | Msg("Reconnecting to RabbitMQ")
129 |
130 | time.Sleep(retryInterval)
131 |
132 | rabbitURL := os.Getenv("RABBITMQ_URL")
133 | conn, err := amqp091.Dial(rabbitURL)
134 | if err != nil {
135 | log.Warn().
136 | Err(err).
137 | Int("attempt", attempt).
138 | Msg("Reconnection failed")
139 | continue
140 | }
141 |
142 | channel, err := conn.Channel()
143 | if err != nil {
144 | conn.Close()
145 | log.Warn().
146 | Err(err).
147 | Int("attempt", attempt).
148 | Msg("Failed to open channel on reconnection")
149 | continue
150 | }
151 |
152 | // Reconnection successful
153 | rabbitConn = conn
154 | rabbitChannel = channel
155 | rabbitEnabled = true
156 |
157 | log.Info().Msg("RabbitMQ reconnected successfully")
158 |
159 | // Restart monitoring
160 | go handleConnectionErrors()
161 | return
162 | }
163 |
164 | log.Error().Msg("Failed to reconnect to RabbitMQ after all retries")
165 | return
166 | }
167 | }
168 |
169 | // Optionally, allow overriding the queue per message
170 | func PublishToRabbit(data []byte, queueOverride ...string) error {
171 | if !rabbitEnabled {
172 | return nil
173 | }
174 | queueName := rabbitQueue
175 | if len(queueOverride) > 0 && queueOverride[0] != "" {
176 | queueName = queueOverride[0]
177 | }
178 | // Declare queue (idempotent)
179 | _, err := rabbitChannel.QueueDeclare(
180 | queueName,
181 | true, // durable
182 | false, // auto-delete
183 | false, // exclusive
184 | false, // no-wait
185 | nil, // arguments
186 | )
187 | if err != nil {
188 | log.Error().Err(err).Str("queue", queueName).Msg("Could not declare RabbitMQ queue")
189 | return err
190 | }
191 | err = rabbitChannel.Publish(
192 | "", // exchange (default)
193 | queueName, // routing key = queue
194 | false, // mandatory
195 | false, // immediate
196 | amqp091.Publishing{
197 | ContentType: "application/json",
198 | Body: data,
199 | DeliveryMode: amqp091.Persistent,
200 | },
201 | )
202 | if err != nil {
203 | log.Error().Err(err).Str("queue", queueName).Msg("Could not publish to RabbitMQ")
204 | } else {
205 | log.Debug().Str("queue", queueName).Msg("Published message to RabbitMQ")
206 | }
207 | return err
208 | }
209 |
210 | func sendToGlobalRabbit(jsonData []byte, token string, userID string, queueName ...string) {
211 | if !rabbitEnabled {
212 | // Check if RabbitMQ is configured but disabled due to connection issues
213 | rabbitURL := os.Getenv("RABBITMQ_URL")
214 | rabbitQueueEnv := os.Getenv("RABBITMQ_QUEUE")
215 |
216 | if rabbitURL != "" || rabbitQueueEnv != "" {
217 | urlSet := "no"
218 | if rabbitURL != "" {
219 | urlSet = "yes"
220 | }
221 | queueSet := "no"
222 | if rabbitQueueEnv != "" {
223 | queueSet = "yes"
224 | }
225 | log.Error().
226 | Str("rabbitmq_url_set", urlSet).
227 | Str("rabbitmq_queue_set", queueSet).
228 | Msg("RabbitMQ is configured but disabled due to connection failure. Event not published to queue.")
229 | } else {
230 | log.Debug().Msg("RabbitMQ not configured. Event not published to queue.")
231 | }
232 | return
233 | }
234 |
235 | // Extract instance information
236 | instance_name := ""
237 | userinfo, found := userinfocache.Get(token)
238 | if found {
239 | instance_name = userinfo.(Values).Get("Name")
240 | }
241 |
242 | // Parse the original JSON into a map
243 | var originalData map[string]interface{}
244 | err := json.Unmarshal(jsonData, &originalData)
245 | if err != nil {
246 | log.Error().Err(err).Msg("Failed to unmarshal original JSON data for RabbitMQ")
247 | return
248 | }
249 |
250 | // Add the new fields directly to the original data
251 | originalData["userID"] = userID
252 | originalData["instanceName"] = instance_name
253 |
254 | // Marshal back to JSON
255 | enhancedJSON, err := json.Marshal(originalData)
256 | if err != nil {
257 | log.Error().Err(err).Msg("Failed to marshal enhanced data for RabbitMQ")
258 | return
259 | }
260 |
261 | err = PublishToRabbit(enhancedJSON, queueName...)
262 | if err != nil {
263 | log.Error().Err(err).Msg("Failed to publish to RabbitMQ")
264 | }
265 | }
266 |
267 | func PublishFileErrorToQueue(payload WebhookFileErrorPayload) {
268 |
269 | queueName := *webhookErrorQueueName
270 |
271 | body, err := json.Marshal(payload)
272 | if err != nil {
273 | log.Error().Err(err).Msg("Failed to marshal file error payload for RabbitMQ")
274 | return
275 | }
276 |
277 | err = PublishToRabbit(body, queueName)
278 | if err != nil {
279 | log.Error().Str("queue", queueName).Msg("Failed to publish file error payload to queue")
280 | } else {
281 | log.Info().Str("queue", queueName).Msg("File error payload successfully published to queue")
282 | }
283 | }
284 |
285 | func PublishDataErrorToQueue(payload WebhookErrorPayload) {
286 | queueName := *webhookErrorQueueName
287 | body, err := json.Marshal(payload)
288 | if err != nil {
289 | log.Error().Err(err).Msg("Failed to marshal data error payload for RabbitMQ")
290 | return
291 | }
292 | err = PublishToRabbit(body, queueName)
293 | if err != nil {
294 | log.Error().Str("queue", queueName).Msg("Failed to publish data error payload to queue")
295 | } else {
296 | log.Info().Str("queue", queueName).Msg("Data error payload successfully published to queue")
297 | }
298 | }
299 |
--------------------------------------------------------------------------------
/static/login/styles.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --primary-color: #00A884; /* WhatsApp's new brand green */
3 | --secondary-color: #008069; /* WhatsApp's darker green */
4 | --tertiary-color: #075E54; /* WhatsApp's darkest green */
5 | --background-color: #FCF5EB; /* WhatsApp's authentic background */
6 | --card-hover-color: #ffffff;
7 | --text-color: #111B21; /* WhatsApp's text color */
8 | --gradient-start: #00A884;
9 | --gradient-end: #008069;
10 | --message-background: #FFFFFF;
11 | --success-green: #00A884;
12 | }
13 |
14 | body {
15 | background-color: var(--background-color) !important;
16 | color: var(--text-color) !important;
17 | font-family: "Helvetica Neue", "Helvetica Neue", Helvetica, Arial, sans-serif !important;
18 | }
19 |
20 | .container {
21 | padding: 2em 1em !important;
22 | max-width: 1400px !important;
23 | margin: 0 auto !important;
24 | }
25 |
26 | .main-header {
27 | background: var(--primary-color) !important;
28 | color: white !important;
29 | padding: 1.5em 2em !important;
30 | border-radius: 24px !important;
31 | margin-bottom: 2em !important;
32 | box-shadow:
33 | 0 20px 40px rgba(0, 168, 132, 0.2),
34 | inset 0 0 80px rgba(255, 255, 255, 0.15) !important;
35 | position: relative;
36 | overflow: hidden;
37 | backdrop-filter: blur(10px);
38 | border: 1px solid rgba(255, 255, 255, 0.2);
39 | transition: all 0.3s ease-in-out;
40 | }
41 |
42 | .hidden {
43 | display:none !important;
44 | }
45 |
46 | .ui.stackable.cards > .card.hidden {
47 | display: none !important;
48 | }
49 |
50 | .main-header::before {
51 | content: '';
52 | position: absolute;
53 | top: 0;
54 | left: 0;
55 | right: 0;
56 | bottom: 0;
57 | background:
58 | radial-gradient(
59 | circle at top right,
60 | rgba(255,255,255,0.2) 0%,
61 | rgba(255,255,255,0) 60%
62 | ),
63 | linear-gradient(
64 | 45deg,
65 | rgba(255,255,255,0.1) 0%,
66 | rgba(255,255,255,0) 70%
67 | );
68 | opacity: 0.8;
69 | z-index: 1;
70 | }
71 |
72 | .rotate-main-header::after {
73 | content: '';
74 | position: absolute;
75 | top: -50%;
76 | left: -50%;
77 | right: -50%;
78 | bottom: -50%;
79 | background:
80 | radial-gradient(
81 | circle,
82 | rgba(255,255,255,0.1) 0%,
83 | transparent 70%
84 | );
85 | animation: rotate 20s linear infinite;
86 | z-index: 0;
87 | }
88 |
89 | @keyframes rotate {
90 | from { transform: rotate(0deg); }
91 | to { transform: rotate(360deg); }
92 | }
93 |
94 | .main-header::after {
95 | content: '';
96 | position: absolute;
97 | top: 0;
98 | left: 0;
99 | right: 0;
100 | bottom: 0;
101 | background:
102 | radial-gradient(
103 | circle,
104 | rgba(255,5,255,0.5) 0%,
105 | transparent 70%
106 | );
107 | animation: pulse 3s ease-in-out infinite;
108 | z-index: 0;
109 | }
110 |
111 | @keyframes pulse {
112 | 0%, 100% { transform: scale(1); opacity: 0.8; }
113 | 50% { transform: scale(1.2); opacity: 0.4; }
114 | }
115 |
116 | .main-header .ui.header {
117 | position: relative;
118 | z-index: 2;
119 | margin: 0 !important;
120 | font-weight: 700 !important;
121 | font-size: clamp(1.5em, 3vw, 1.8em) !important;
122 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2) !important;
123 | display: flex;
124 | flex-direction: column;
125 | align-items: center;
126 | justify-content: center;
127 | gap: 8px;
128 | letter-spacing: 0.5px;
129 | }
130 |
131 | .main-header .title-container {
132 | display: flex;
133 | align-items: center;
134 | gap: 8px;
135 | color: white;
136 | }
137 |
138 | .main-header .whatsapp.icon {
139 | font-size: 1em !important;
140 | }
141 |
142 | .main-header .whatsapp.icon:hover {
143 | transform: scale(1.1);
144 | background: rgba(255, 255, 255, 0.2);
145 | }
146 |
147 | .main-header .version-label {
148 | margin-top: 4px !important;
149 | background: rgba(255,255,255,0.12);
150 | color: white;
151 | font-size: 0.5em;
152 | padding: 4px 10px;
153 | border-radius: 12px;
154 | backdrop-filter: blur(4px);
155 | border: 1px solid rgba(255, 255, 255, 0.15);
156 | transition: all 0.25s ease;
157 | letter-spacing: 0.5px;
158 | box-shadow: 0 2px 4px rgba(0,0,0,0.1);
159 | }
160 |
161 | .main-header .version-label:hover {
162 | background: rgba(255,255,255,0.2);
163 | transform: translateY(-2px);
164 | }
165 |
166 | @media (min-width: 768px) {
167 | .main-header {
168 | padding: 2em !important;
169 | }
170 |
171 | .main-header .ui.header {
172 | flex-direction: column;
173 | }
174 |
175 | .main-header .version-label {
176 | margin-top: 4px !important;
177 | }
178 | }
179 |
180 | @keyframes float {
181 | 0% { transform: translateY(0px) rotate(0deg); }
182 | 50% { transform: translateY(-8px) rotate(5deg); }
183 | 100% { transform: translateY(0px) rotate(0deg); }
184 | }
185 |
186 | .ui.header {
187 | font-weight: 600 !important;
188 | color: var(--text-color) !important;
189 | }
190 |
191 | .ui.cards > .card {
192 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05) !important;
193 | transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important;
194 | border-radius: 16px !important;
195 | margin: 0.7em !important;
196 | background-color: var(--message-background) !important;
197 | border: 1px solid rgba(18, 140, 126, 0.1);
198 | }
199 |
200 | .ui.cards > .card:hover {
201 | transform: translateY(-8px) !important;
202 | box-shadow: 0 12px 24px rgba(18, 140, 126, 0.15) !important;
203 | background-color: var(--card-hover-color) !important;
204 | }
205 |
206 | .ui.horizontal.divider {
207 | font-size: 1.3em !important;
208 | color: var(--secondary-color) !important;
209 | margin: 2.5em 0 !important;
210 | font-weight: 700 !important;
211 | text-transform: uppercase;
212 | letter-spacing: 1px;
213 | }
214 |
215 | .ui.success.message {
216 | border-radius: 16px !important;
217 | box-shadow: 0 4px 8px rgba(37, 211, 102, 0.1) !important;
218 | border: 1px solid rgba(37, 211, 102, 0.2) !important;
219 | background-color: rgba(231, 247, 232, 0.8) !important;
220 | color: var(--success-green) !important;
221 | animation: slideIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
222 | backdrop-filter: blur(10px);
223 | }
224 |
225 | @keyframes slideIn {
226 | from { transform: translateX(-30px); opacity: 0; }
227 | to { transform: translateX(0); opacity: 1; }
228 | }
229 |
230 | .ui.button {
231 | border-radius: 12px !important;
232 | transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important;
233 | /* background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%) !important; */
234 | /* background: var(--primary-color) !important; */
235 | color: white !important;
236 | font-weight: 600 !important;
237 | letter-spacing: 0.5px;
238 | }
239 |
240 | .ui.button:hover {
241 | transform: translateY(-3px) !important;
242 | box-shadow: 0 8px 16px rgba(0, 168, 132, 0.2) !important;
243 | filter: brightness(0.95);
244 | }
245 |
246 | .ui.form input, .ui.form textarea {
247 | border-radius: 12px !important;
248 | border: 2px solid rgba(18, 140, 126, 0.1) !important;
249 | transition: all 0.3s ease;
250 | }
251 |
252 | .ui.form input:focus, .ui.form textarea:focus {
253 | border-color: var(--primary-color) !important;
254 | box-shadow: 0 0 0 3px rgba(0, 168, 132, 0.1) !important;
255 | }
256 |
257 | /*
258 | .ui.toast-container {
259 | padding: 1.5em !important;
260 | }
261 |
262 | .ui.toast {
263 | border-radius: 16px !important;
264 | box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1) !important;
265 | background-color: var(--message-background) !important;
266 | border: 1px solid rgba(18, 140, 126, 0.1);
267 | }
268 | */
269 |
270 | .ui.grid {
271 | margin: -0.7em !important;
272 | }
273 |
274 | .ui.grid > .column {
275 | padding: 0.7em !important;
276 | }
277 |
278 | @keyframes fadeIn {
279 | from { opacity: 0; transform: translateY(30px); }
280 | to { opacity: 1; transform: translateY(0); }
281 | }
282 |
283 | .ui.cards {
284 | animation: fadeIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
285 | }
286 |
287 | .ui.modal {
288 | border-radius: 20px !important;
289 | background-color: var(--message-background) !important;
290 | overflow: hidden;
291 | }
292 |
293 | .ui.modal > .header {
294 | border-radius: 20px 20px 0 0 !important;
295 | background: var(--gradient-start) !important;
296 | color: white !important;
297 | padding: 1.5em !important;
298 | }
299 |
300 | .ui.modal > .actions {
301 | border-radius: 0 0 20px 20px !important;
302 | background-color: rgba(248, 248, 248, 0.8) !important;
303 | backdrop-filter: blur(10px);
304 | }
305 |
306 | .ui.modal > .actions > .ui.button {
307 | _background: var(--primary-color) !important;
308 | _color: white !important;
309 | font-weight: 600 !important;
310 | letter-spacing: 0.5px;
311 | }
312 |
--------------------------------------------------------------------------------
/static/js/main.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 | // Header scroll effect
3 | const header = document.querySelector('header');
4 | const scrollDownBtn = document.querySelector('.scroll-down');
5 | const menuToggle = document.querySelector('.menu-toggle');
6 | const mainMenu = document.querySelector('#main-menu');
7 |
8 | // Menu mobile toggle
9 | if (menuToggle && mainMenu) {
10 | menuToggle.addEventListener('click', function() {
11 | menuToggle.classList.toggle('active');
12 | mainMenu.classList.toggle('active');
13 | document.body.classList.toggle('menu-open');
14 | });
15 |
16 | // Close menu when clicking on a link
17 | const menuLinks = mainMenu.querySelectorAll('a');
18 | menuLinks.forEach(link => {
19 | link.addEventListener('click', function() {
20 | menuToggle.classList.remove('active');
21 | mainMenu.classList.remove('active');
22 | document.body.classList.remove('menu-open');
23 | });
24 | });
25 | }
26 |
27 | if (scrollDownBtn) {
28 | scrollDownBtn.addEventListener('click', function() {
29 | const featuresSection = document.querySelector('#features');
30 | if (featuresSection) {
31 | featuresSection.scrollIntoView({ behavior: 'smooth' });
32 | }
33 | });
34 | }
35 |
36 | window.addEventListener('scroll', function() {
37 | if (window.scrollY > 100) {
38 | header.classList.add('scrolled');
39 | } else {
40 | header.classList.remove('scrolled');
41 | }
42 |
43 | // Animation on scroll
44 | const animatedElements = document.querySelectorAll('.fade-in, .slide-in-left, .slide-in-right, .zoom-in');
45 |
46 | animatedElements.forEach(element => {
47 | const elementPosition = element.getBoundingClientRect().top;
48 | const windowHeight = window.innerHeight;
49 |
50 | if (elementPosition < windowHeight - 100) {
51 | element.classList.add('active');
52 | }
53 | });
54 | });
55 |
56 | // Dark mode toggle
57 | const themeToggle = document.querySelector('.theme-toggle');
58 |
59 | if (themeToggle) {
60 | themeToggle.addEventListener('click', function() {
61 | document.body.classList.toggle('dark-mode');
62 |
63 | // Update the icon
64 | const icon = themeToggle.querySelector('i');
65 | if (document.body.classList.contains('dark-mode')) {
66 | icon.classList.remove('fa-moon');
67 | icon.classList.add('fa-sun');
68 | localStorage.setItem('theme', 'dark');
69 | } else {
70 | icon.classList.remove('fa-sun');
71 | icon.classList.add('fa-moon');
72 | localStorage.setItem('theme', 'light');
73 | }
74 | });
75 |
76 | // Check for saved theme preference
77 | const savedTheme = localStorage.getItem('theme');
78 | if (savedTheme === 'dark') {
79 | document.body.classList.add('dark-mode');
80 | const icon = themeToggle.querySelector('i');
81 | icon.classList.remove('fa-moon');
82 | icon.classList.add('fa-sun');
83 | }
84 | }
85 |
86 | // Interactive demo
87 | initDemo();
88 |
89 | // Start coffee animation
90 | animateCoffee();
91 | });
92 |
93 | function initDemo() {
94 | const demoSection = document.querySelector('.demo-section');
95 | if (!demoSection) return;
96 |
97 | const chatBody = document.querySelector('.chat-body');
98 | const demoButtons = document.querySelectorAll('.demo-btn');
99 |
100 | const demoScenarios = {
101 | 'message': [
102 | { type: 'received', content: 'Hello! How can I help you?' },
103 | { type: 'sent', content: 'I want to send a message to 5491155551234' },
104 | { type: 'received', content: 'Ok! I will show you how to use the API to send a text message:' },
105 | { type: 'code', content: `POST /chat/send/text
106 | {
107 | "Phone": "5491155551234",
108 | "Body": "Hello, this was sent via WuzAPI!"
109 | }`},
110 | { type: 'received', content: 'Cool! Message was sent successfully.' }
111 | ],
112 | 'media': [
113 | { type: 'sent', content: 'How do I send an image?' },
114 | { type: 'typing', content: '' },
115 | { type: 'received', content: 'To send an image, use the endpoint /chat/send/image:' },
116 | { type: 'code', content: `POST /chat/send/image
117 | {
118 | "Phone": "55912345678",
119 | "Image": "data:image/jpeg;base64,...",
120 | "Caption": "Look at this image!"
121 | }`},
122 | { type: 'received', content: 'You can also send other media types like audio, documents and videos.' }
123 | ],
124 | 'webhook': [
125 | { type: 'sent', content: 'How do I set up webhooks to receive messages?' },
126 | { type: 'typing', content: '' },
127 | { type: 'received', content: 'You can configure webhooks using the endpoint /webhook:' },
128 | { type: 'code', content: `POST /webhook
129 | {
130 | "webhook": "https://myserver.com/webhook",
131 | "events": ["Message", "ReadReceipt"]
132 | }`},
133 | { type: 'received', content: 'This will configure your server to receive notifications for new messages and read receipts.' }
134 | ]
135 | };
136 |
137 | if (demoButtons.length > 0 && chatBody) {
138 | demoButtons.forEach(button => {
139 | button.addEventListener('click', function() {
140 | // Add active class to current button and remove from others
141 | demoButtons.forEach(btn => btn.classList.remove('active-btn'));
142 | button.classList.add('active-btn');
143 |
144 | const scenario = button.getAttribute('data-scenario');
145 | if (demoScenarios[scenario]) {
146 | playDemoScenario(demoScenarios[scenario], chatBody);
147 | }
148 | });
149 | });
150 |
151 | // Auto play the first scenario
152 | setTimeout(() => {
153 | demoButtons[0].classList.add('active-btn');
154 | playDemoScenario(demoScenarios['message'], chatBody);
155 | }, 1000);
156 | }
157 | }
158 |
159 | function playDemoScenario(scenario, chatBody) {
160 | // Clear the chat
161 | chatBody.innerHTML = '';
162 |
163 | // Play the scenario with delays
164 | let delay = 0;
165 |
166 | scenario.forEach((message, index) => {
167 | delay += message.type === 'typing' ? 500 : 1000;
168 |
169 | setTimeout(() => {
170 | if (message.type === 'typing') {
171 | addTypingIndicator(chatBody);
172 | } else {
173 | // Remove typing indicator if exists
174 | const typingIndicator = chatBody.querySelector('.typing-indicator');
175 | if (typingIndicator) {
176 | typingIndicator.remove();
177 | }
178 |
179 | // Add the message
180 | const messageDiv = document.createElement('div');
181 |
182 | if (message.type === 'code') {
183 | messageDiv.className = 'chat-message received';
184 |
185 | // Improved code presentation with better formatting
186 | const codeContent = message.content
187 | .replace(/\{/g, '{ ')
188 | .replace(/\}/g, ' }')
189 | .replace(/,/g, ', ');
190 |
191 | messageDiv.innerHTML = `${codeContent} `;
192 | } else {
193 | messageDiv.className = `chat-message ${message.type}`;
194 | messageDiv.textContent = message.content;
195 | }
196 |
197 | chatBody.appendChild(messageDiv);
198 | chatBody.scrollTop = chatBody.scrollHeight;
199 | }
200 | }, delay);
201 | });
202 | }
203 |
204 | function addTypingIndicator(chatBody) {
205 | // Remove existing typing indicator
206 | const existingIndicator = chatBody.querySelector('.typing-indicator');
207 | if (existingIndicator) {
208 | existingIndicator.remove();
209 | }
210 |
211 | // Add typing indicator
212 | const typingIndicator = document.createElement('div');
213 | typingIndicator.className = 'typing-indicator';
214 |
215 | for (let i = 0; i < 3; i++) {
216 | const dot = document.createElement('div');
217 | dot.className = 'typing-dot';
218 | typingIndicator.appendChild(dot);
219 | }
220 |
221 | chatBody.appendChild(typingIndicator);
222 | chatBody.scrollTop = chatBody.scrollHeight;
223 | }
224 |
225 | // Coffee animation
226 | function animateCoffee() {
227 | const coffeeSteam = document.querySelector('.coffee-steam');
228 | if (coffeeSteam) {
229 | coffeeSteam.style.opacity = '1';
230 |
231 | setTimeout(() => {
232 | coffeeSteam.style.opacity = '0';
233 |
234 | setTimeout(animateCoffee, 3000);
235 | }, 2000);
236 | }
237 | }
238 |
239 | // Smooth scrolling for all internal links
240 | document.querySelectorAll('a[href^="#"]').forEach(anchor => {
241 | anchor.addEventListener('click', function(e) {
242 | e.preventDefault();
243 |
244 | const targetId = this.getAttribute('href');
245 | const targetElement = document.querySelector(targetId);
246 |
247 | if (targetElement) {
248 | // Offset for fixed header
249 | const headerHeight = document.querySelector('header').offsetHeight;
250 | const targetPosition = targetElement.getBoundingClientRect().top + window.pageYOffset - headerHeight;
251 |
252 | window.scrollTo({
253 | top: targetPosition,
254 | behavior: 'smooth'
255 | });
256 | }
257 | });
258 | });
259 |
--------------------------------------------------------------------------------
/s3manager.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "strings"
8 | "sync"
9 | "time"
10 |
11 | "github.com/aws/aws-sdk-go-v2/aws"
12 | "github.com/aws/aws-sdk-go-v2/credentials"
13 | "github.com/aws/aws-sdk-go-v2/service/s3"
14 | "github.com/aws/aws-sdk-go-v2/service/s3/types"
15 | "github.com/rs/zerolog/log"
16 | )
17 |
18 | // S3Config holds S3 configuration for a user
19 | type S3Config struct {
20 | Enabled bool
21 | Endpoint string
22 | Region string
23 | Bucket string
24 | AccessKey string
25 | SecretKey string
26 | PathStyle bool
27 | PublicURL string
28 | MediaDelivery string
29 | RetentionDays int
30 | }
31 |
32 | // S3Manager manages S3 operations
33 | type S3Manager struct {
34 | mu sync.RWMutex
35 | clients map[string]*s3.Client
36 | configs map[string]*S3Config
37 | }
38 |
39 | // Global S3 manager instance
40 | var s3Manager = &S3Manager{
41 | clients: make(map[string]*s3.Client),
42 | configs: make(map[string]*S3Config),
43 | }
44 |
45 | // GetS3Manager returns the global S3 manager instance
46 | func GetS3Manager() *S3Manager {
47 | return s3Manager
48 | }
49 |
50 | // InitializeS3Client creates or updates S3 client for a user
51 | func (m *S3Manager) InitializeS3Client(userID string, config *S3Config) error {
52 | if !config.Enabled {
53 | m.RemoveClient(userID)
54 | return nil
55 | }
56 |
57 | m.mu.Lock()
58 | defer m.mu.Unlock()
59 |
60 | // Create custom credentials provider
61 | credProvider := credentials.NewStaticCredentialsProvider(
62 | config.AccessKey,
63 | config.SecretKey,
64 | "",
65 | )
66 |
67 | // Configure S3 client
68 | cfg := aws.Config{
69 | Region: config.Region,
70 | Credentials: credProvider,
71 | }
72 |
73 | if config.Endpoint != "" {
74 | customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
75 | if service == s3.ServiceID {
76 | return aws.Endpoint{
77 | URL: config.Endpoint,
78 | HostnameImmutable: config.PathStyle,
79 | }, nil
80 | }
81 | return aws.Endpoint{}, &aws.EndpointNotFoundError{}
82 | })
83 | cfg.EndpointResolverWithOptions = customResolver
84 | }
85 |
86 | // Create S3 client
87 | client := s3.NewFromConfig(cfg, func(o *s3.Options) {
88 | o.UsePathStyle = config.PathStyle
89 | })
90 |
91 | m.clients[userID] = client
92 | m.configs[userID] = config
93 |
94 | log.Info().Str("userID", userID).Str("bucket", config.Bucket).Msg("S3 client initialized")
95 | return nil
96 | }
97 |
98 | // RemoveClient removes S3 client for a user
99 | func (m *S3Manager) RemoveClient(userID string) {
100 | m.mu.Lock()
101 | defer m.mu.Unlock()
102 |
103 | delete(m.clients, userID)
104 | delete(m.configs, userID)
105 | }
106 |
107 | // GetClient returns S3 client for a user
108 | func (m *S3Manager) GetClient(userID string) (*s3.Client, *S3Config, bool) {
109 | m.mu.RLock()
110 | defer m.mu.RUnlock()
111 |
112 | client, clientOk := m.clients[userID]
113 | config, configOk := m.configs[userID]
114 |
115 | return client, config, clientOk && configOk
116 | }
117 |
118 | // GenerateS3Key generates S3 object key based on message metadata
119 | func (m *S3Manager) GenerateS3Key(userID, contactJID, messageID string, mimeType string, isIncoming bool) string {
120 | // Determine direction
121 | direction := "outbox"
122 | if isIncoming {
123 | direction = "inbox"
124 | }
125 |
126 | // Clean contact JID
127 | contactJID = strings.ReplaceAll(contactJID, "@", "_")
128 | contactJID = strings.ReplaceAll(contactJID, ":", "_")
129 |
130 | // Get current time
131 | now := time.Now()
132 | year := now.Format("2025")
133 | month := now.Format("05")
134 | day := now.Format("25")
135 |
136 | // Determine media type folder
137 | mediaType := "documents"
138 | if strings.HasPrefix(mimeType, "image/") {
139 | mediaType = "images"
140 | } else if strings.HasPrefix(mimeType, "video/") {
141 | mediaType = "videos"
142 | } else if strings.HasPrefix(mimeType, "audio/") {
143 | mediaType = "audio"
144 | }
145 |
146 | // Get file extension
147 | ext := ".bin"
148 | switch {
149 | case strings.Contains(mimeType, "jpeg"), strings.Contains(mimeType, "jpg"):
150 | ext = ".jpg"
151 | case strings.Contains(mimeType, "png"):
152 | ext = ".png"
153 | case strings.Contains(mimeType, "gif"):
154 | ext = ".gif"
155 | case strings.Contains(mimeType, "webp"):
156 | ext = ".webp"
157 | case strings.Contains(mimeType, "mp4"):
158 | ext = ".mp4"
159 | case strings.Contains(mimeType, "webm"):
160 | ext = ".webm"
161 | case strings.Contains(mimeType, "ogg"):
162 | ext = ".ogg"
163 | case strings.Contains(mimeType, "opus"):
164 | ext = ".opus"
165 | case strings.Contains(mimeType, "pdf"):
166 | ext = ".pdf"
167 | case strings.Contains(mimeType, "doc"):
168 | if strings.Contains(mimeType, "docx") {
169 | ext = ".docx"
170 | } else {
171 | ext = ".doc"
172 | }
173 | }
174 |
175 | // Build S3 key
176 | key := fmt.Sprintf("users/%s/%s/%s/%s/%s/%s/%s/%s%s",
177 | userID,
178 | direction,
179 | contactJID,
180 | year,
181 | month,
182 | day,
183 | mediaType,
184 | messageID,
185 | ext,
186 | )
187 |
188 | return key
189 | }
190 |
191 | // UploadToS3 uploads file to S3 and returns the key
192 | func (m *S3Manager) UploadToS3(ctx context.Context, userID string, key string, data []byte, mimeType string) error {
193 | client, config, ok := m.GetClient(userID)
194 | if !ok {
195 | return fmt.Errorf("S3 client not initialized for user %s", userID)
196 | }
197 |
198 | // Set content type and cache headers for preview
199 | contentType := mimeType
200 | if contentType == "" {
201 | contentType = "application/octet-stream"
202 | }
203 |
204 | // Calculate expiration time based on retention days
205 | var expires *time.Time
206 | if config.RetentionDays > 0 {
207 | expirationTime := time.Now().Add(time.Duration(config.RetentionDays) * 24 * time.Hour)
208 | expires = &expirationTime
209 | }
210 |
211 | input := &s3.PutObjectInput{
212 | Bucket: aws.String(config.Bucket),
213 | Key: aws.String(key),
214 | Body: bytes.NewReader(data),
215 | ContentType: aws.String(contentType),
216 | CacheControl: aws.String("public, max-age=3600"),
217 | ACL: types.ObjectCannedACLPublicRead,
218 | }
219 |
220 | if expires != nil {
221 | input.Expires = expires
222 | }
223 |
224 | // Add content disposition for inline preview
225 | if strings.HasPrefix(mimeType, "image/") || strings.HasPrefix(mimeType, "video/") || mimeType == "application/pdf" {
226 | input.ContentDisposition = aws.String("inline")
227 | }
228 |
229 | _, err := client.PutObject(ctx, input)
230 | if err != nil {
231 | return fmt.Errorf("failed to upload to S3: %w", err)
232 | }
233 |
234 | return nil
235 | }
236 |
237 | // GetPublicURL generates public URL for S3 object
238 | func (m *S3Manager) GetPublicURL(userID, key string) string {
239 | _, config, ok := m.GetClient(userID)
240 | if !ok {
241 | return ""
242 | }
243 |
244 | // Use custom public URL if configured
245 | if config.PublicURL != "" {
246 | return fmt.Sprintf("%s/%s/%s", strings.TrimRight(config.PublicURL, "/"), config.Bucket, key)
247 | }
248 |
249 | // Generate standard S3 URL
250 | if config.PathStyle {
251 | return fmt.Sprintf("%s/%s/%s",
252 | strings.TrimRight(config.Endpoint, "/"),
253 | config.Bucket,
254 | key)
255 | }
256 |
257 | // Virtual hosted-style URL
258 | if strings.Contains(config.Endpoint, "amazonaws.com") {
259 | return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s",
260 | config.Bucket,
261 | config.Region,
262 | key)
263 | }
264 |
265 | // For other S3-compatible services
266 | endpoint := strings.TrimPrefix(config.Endpoint, "https://")
267 | endpoint = strings.TrimPrefix(endpoint, "http://")
268 | return fmt.Sprintf("https://%s.%s/%s", config.Bucket, endpoint, key)
269 | }
270 |
271 | // TestConnection tests S3 connection
272 | func (m *S3Manager) TestConnection(ctx context.Context, userID string) error {
273 | client, config, ok := m.GetClient(userID)
274 | if !ok {
275 | return fmt.Errorf("S3 client not initialized for user %s", userID)
276 | }
277 |
278 | // Try to list objects with max 1 result
279 | input := &s3.ListObjectsV2Input{
280 | Bucket: aws.String(config.Bucket),
281 | MaxKeys: aws.Int32(1),
282 | }
283 |
284 | _, err := client.ListObjectsV2(ctx, input)
285 | return err
286 | }
287 |
288 | // ProcessMediaForS3 handles the complete media upload process
289 | func (m *S3Manager) ProcessMediaForS3(ctx context.Context, userID, contactJID, messageID string,
290 | data []byte, mimeType string, fileName string, isIncoming bool) (map[string]interface{}, error) {
291 |
292 | // Generate S3 key
293 | key := m.GenerateS3Key(userID, contactJID, messageID, mimeType, isIncoming)
294 |
295 | // Upload to S3
296 | err := m.UploadToS3(ctx, userID, key, data, mimeType)
297 | if err != nil {
298 | return nil, fmt.Errorf("failed to upload to S3: %w", err)
299 | }
300 |
301 | // Generate public URL
302 | publicURL := m.GetPublicURL(userID, key)
303 |
304 | // Return S3 metadata
305 | s3Data := map[string]interface{}{
306 | "url": publicURL,
307 | "key": key,
308 | "bucket": m.configs[userID].Bucket,
309 | "size": len(data),
310 | "mimeType": mimeType,
311 | "fileName": fileName,
312 | }
313 |
314 | return s3Data, nil
315 | }
316 |
317 | // DeleteAllUserObjects deletes all user files from S3
318 | func (m *S3Manager) DeleteAllUserObjects(ctx context.Context, userID string) error {
319 | client, config, ok := m.GetClient(userID)
320 | if !ok {
321 | return fmt.Errorf("S3 client not initialized for user %s", userID)
322 | }
323 |
324 | prefix := fmt.Sprintf("users/%s/", userID)
325 | var toDelete []types.ObjectIdentifier
326 | var continuationToken *string
327 |
328 | for {
329 | input := &s3.ListObjectsV2Input{
330 | Bucket: aws.String(config.Bucket),
331 | Prefix: aws.String(prefix),
332 | ContinuationToken: continuationToken,
333 | }
334 | output, err := client.ListObjectsV2(ctx, input)
335 | if err != nil {
336 | return fmt.Errorf("failed to list objects for user %s: %w", userID, err)
337 | }
338 |
339 | for _, obj := range output.Contents {
340 | toDelete = append(toDelete, types.ObjectIdentifier{Key: obj.Key})
341 | // Delete in batches of 1000 (S3 limit)
342 | if len(toDelete) == 1000 {
343 | _, err := client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
344 | Bucket: aws.String(config.Bucket),
345 | Delete: &types.Delete{Objects: toDelete},
346 | })
347 | if err != nil {
348 | return fmt.Errorf("failed to delete objects for user %s: %w", userID, err)
349 | }
350 | toDelete = nil
351 | }
352 | }
353 |
354 | if output.IsTruncated != nil && *output.IsTruncated && output.NextContinuationToken != nil {
355 | continuationToken = output.NextContinuationToken
356 | } else {
357 | break
358 | }
359 | }
360 |
361 | // Delete any remaining objects
362 | if len(toDelete) > 0 {
363 | _, err := client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
364 | Bucket: aws.String(config.Bucket),
365 | Delete: &types.Delete{Objects: toDelete},
366 | })
367 | if err != nil {
368 | return fmt.Errorf("failed to delete objects for user %s: %w", userID, err)
369 | }
370 | }
371 |
372 | log.Info().Str("userID", userID).Msg("all user files removed from S3")
373 | return nil
374 | }
375 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "fmt"
7 | "math/rand"
8 | "net"
9 | "net/http"
10 | "os"
11 | "os/signal"
12 | "path/filepath"
13 | "strconv"
14 | "strings"
15 | "sync"
16 | "syscall"
17 | "time"
18 |
19 | "go.mau.fi/whatsmeow/store/sqlstore"
20 | waLog "go.mau.fi/whatsmeow/util/log"
21 |
22 | "github.com/gorilla/mux"
23 | "github.com/jmoiron/sqlx"
24 | "github.com/joho/godotenv"
25 | _ "github.com/lib/pq"
26 | "github.com/patrickmn/go-cache"
27 | "github.com/rs/zerolog"
28 | "github.com/rs/zerolog/log"
29 | )
30 |
31 | // ServerMode represents the server operating mode
32 | type ServerMode int
33 |
34 | const (
35 | HTTP ServerMode = iota
36 | Stdio
37 | )
38 |
39 | type server struct {
40 | db *sqlx.DB
41 | router *mux.Router
42 | exPath string
43 | mode ServerMode
44 | }
45 |
46 | // Replace the global variables
47 | var (
48 | address = flag.String("address", "0.0.0.0", "Bind IP Address")
49 | port = flag.String("port", "8080", "Listen Port")
50 | waDebug = flag.String("wadebug", "", "Enable whatsmeow debug (INFO or DEBUG)")
51 | logType = flag.String("logtype", "console", "Type of log output (console or json)")
52 | skipMedia = flag.Bool("skipmedia", false, "Do not attempt to download media in messages")
53 | osName = flag.String("osname", "Mac OS 10", "Connection OSName in Whatsapp")
54 | colorOutput = flag.Bool("color", false, "Enable colored output for console logs")
55 | sslcert = flag.String("sslcertificate", "", "SSL Certificate File")
56 | sslprivkey = flag.String("sslprivatekey", "", "SSL Certificate Private Key File")
57 | adminToken = flag.String("admintoken", "", "Security Token to authorize admin actions (list/create/remove users)")
58 | globalEncryptionKey = flag.String("globalencryptionkey", "", "Encryption key for sensitive data (32 bytes)")
59 | globalHMACKey = flag.String("globalhmackey", "", "Global HMAC key for webhook signing")
60 | globalWebhook = flag.String("globalwebhook", "", "Global webhook URL to receive all events from all users")
61 | versionFlag = flag.Bool("version", false, "Display version information and exit")
62 | mode = flag.String("mode", "http", "Server mode: http or stdio")
63 | dataDir = flag.String("datadir", "", "Data directory for database and session files (defaults to executable directory)")
64 |
65 | globalHMACKeyEncrypted []byte
66 |
67 | webhookRetryEnabled = flag.Bool("webhookretry", true, "Enable webhook retry mechanism")
68 | webhookRetryCount = flag.Int("retrycount", 5, "Number of times to retry failed webhooks")
69 | webhookRetryDelaySeconds = flag.Int("retrydelay", 30, "Delay in seconds between webhook retries")
70 | webhookErrorQueueName = flag.String("errorqueue", "webhook_errors", "RabbitMQ queue name for failed webhooks")
71 |
72 | container *sqlstore.Container
73 | clientManager = NewClientManager()
74 | killchannel = make(map[string](chan bool))
75 | userinfocache = cache.New(5*time.Minute, 10*time.Minute)
76 | lastMessageCache = cache.New(24*time.Hour, 24*time.Hour)
77 | globalHTTPClient = newSafeHTTPClient()
78 | )
79 |
80 | var privateIPBlocks []*net.IPNet
81 |
82 | const version = "1.0.5"
83 |
84 | func newSafeHTTPClient() *http.Client {
85 | return &http.Client{
86 | Timeout: 60 * time.Second,
87 | Transport: &http.Transport{
88 | DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
89 | host, port, err := net.SplitHostPort(addr)
90 | if err != nil {
91 | return nil, fmt.Errorf("unexpected address format from http transport: %q: %w", addr, err)
92 | }
93 |
94 | ips, err := net.LookupIP(host)
95 | if err != nil {
96 | return nil, fmt.Errorf("failed to resolve host '%s': %w", host, err)
97 | }
98 | if len(ips) == 0 {
99 | return nil, fmt.Errorf("no IP addresses found for host: %s", host)
100 | }
101 |
102 | var (
103 | lastDialErr error
104 | ssrfDetected bool
105 | ssrfLastError error
106 | )
107 |
108 | for _, ip := range ips {
109 | if isPrivateOrLoopback(ip) {
110 | log.Warn().Str("ip", ip.String()).Str("host", host).Msg("SSRF attempt detected: refused to connect to private or local address")
111 | ssrfDetected = true
112 | if ssrfLastError == nil {
113 | ssrfLastError = fmt.Errorf("ssrf attempt detected: host '%s' resolves to one or more private IP addresses", host)
114 | }
115 | continue
116 | }
117 |
118 | dialer := &net.Dialer{
119 | Timeout: 4 * time.Second,
120 | KeepAlive: 30 * time.Second,
121 | }
122 |
123 | connAddr := net.JoinHostPort(ip.String(), port)
124 | conn, err := dialer.DialContext(ctx, network, connAddr)
125 | if err == nil {
126 | return conn, nil
127 | }
128 | lastDialErr = err
129 | }
130 |
131 | if lastDialErr != nil {
132 | return nil, lastDialErr
133 | }
134 | if ssrfDetected {
135 | return nil, ssrfLastError
136 | }
137 | if lastDialErr != nil {
138 | return nil, lastDialErr
139 | }
140 | return nil, fmt.Errorf("no dialable IP addresses found for host %s", host)
141 | },
142 | },
143 | }
144 | }
145 |
146 | func isPrivateOrLoopback(ip net.IP) bool {
147 | if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
148 | return true
149 | }
150 | for _, block := range privateIPBlocks {
151 | if block.Contains(ip) {
152 | return true
153 | }
154 | }
155 | return false
156 | }
157 |
158 | func main() {
159 | for _, cidr := range []string{
160 | "127.0.0.0/8", // IPv4 loopback
161 | "10.0.0.0/8", // RFC1918
162 | "172.16.0.0/12", // RFC1918
163 | "192.168.0.0/16", // RFC1918
164 | "100.64.0.0/10", // RFC6598 Carrier-Grade NAT
165 | "169.254.0.0/16", // RFC3927 link-local
166 | "::1/128", // IPv6 loopback
167 | "fe80::/10", // IPv6 link-local
168 | } {
169 | _, block, err := net.ParseCIDR(cidr)
170 | if err != nil {
171 | log.Fatal().Err(err).Msgf("Failed to parse CIDR string: %s", cidr)
172 | }
173 | privateIPBlocks = append(privateIPBlocks, block)
174 | }
175 |
176 | err := godotenv.Load()
177 | if err != nil {
178 | log.Warn().Err(err).Msg("It was not possible to load the .env file (it may not exist).")
179 | }
180 |
181 | flag.Parse()
182 |
183 | // Check for address in environment variable if flag is default or empty
184 | if *address == "0.0.0.0" || *address == "" {
185 | if v := os.Getenv("WUZAPI_ADDRESS"); v != "" {
186 | *address = v
187 | log.Info().Str("address", v).Msg("Address configured from environment variable")
188 | }
189 | }
190 |
191 | // Check for port in environment variable if flag is default or empty
192 | if *port == "8080" || *port == "" {
193 | if v := os.Getenv("WUZAPI_PORT"); v != "" {
194 | *port = v
195 | log.Info().Str("port", v).Msg("Port configured from environment variable")
196 | }
197 | }
198 |
199 | if v := os.Getenv("WEBHOOK_RETRY_ENABLED"); v != "" {
200 | *webhookRetryEnabled = strings.ToLower(v) == "true" || v == "1"
201 | }
202 | if v := os.Getenv("WEBHOOK_RETRY_COUNT"); v != "" {
203 | if count, err := strconv.Atoi(v); err == nil {
204 | *webhookRetryCount = count
205 | }
206 | }
207 | if v := os.Getenv("WEBHOOK_RETRY_DELAY_SECONDS"); v != "" {
208 | if delay, err := strconv.Atoi(v); err == nil {
209 | *webhookRetryDelaySeconds = delay
210 | }
211 | }
212 | if v := os.Getenv("WEBHOOK_ERROR_QUEUE_NAME"); v != "" {
213 | *webhookErrorQueueName = v
214 | }
215 |
216 | log.Info().
217 | Bool("enabled", *webhookRetryEnabled).
218 | Int("count", *webhookRetryCount).
219 | Int("delay", *webhookRetryDelaySeconds).
220 | Str("queue", *webhookErrorQueueName).
221 | Msg("Webhook Retry Configured")
222 |
223 | // Novo bloco para sobrescrever o osName pelo ENV, se existir
224 | if v := os.Getenv("SESSION_DEVICE_NAME"); v != "" {
225 | *osName = v
226 | }
227 |
228 | if *versionFlag {
229 | fmt.Printf("WuzAPI version %s\n", version)
230 | os.Exit(0)
231 | }
232 |
233 | // In stdio mode, always log to stderr to avoid interfering with JSON responses on stdout
234 | logOutput := os.Stdout
235 | if *mode == "stdio" {
236 | logOutput = os.Stderr
237 | }
238 |
239 | if *logType == "json" {
240 | log.Logger = zerolog.New(logOutput).
241 | With().
242 | Timestamp().
243 | Str("role", filepath.Base(os.Args[0])).
244 | Logger()
245 | } else {
246 | output := zerolog.ConsoleWriter{
247 | Out: logOutput,
248 | TimeFormat: "2006-01-02 15:04:05 -07:00",
249 | NoColor: !*colorOutput,
250 | }
251 |
252 | output.FormatLevel = func(i interface{}) string {
253 | if i == nil {
254 | return ""
255 | }
256 | lvl := strings.ToUpper(i.(string))
257 | switch lvl {
258 | case "DEBUG":
259 | return "\x1b[34m" + lvl + "\x1b[0m"
260 | case "INFO":
261 | return "\x1b[32m" + lvl + "\x1b[0m"
262 | case "WARN":
263 | return "\x1b[33m" + lvl + "\x1b[0m"
264 | case "ERROR", "FATAL", "PANIC":
265 | return "\x1b[31m" + lvl + "\x1b[0m"
266 | default:
267 | return lvl
268 | }
269 | }
270 |
271 | log.Logger = zerolog.New(output).
272 | With().
273 | Timestamp().
274 | Str("role", filepath.Base(os.Args[0])).
275 | Logger()
276 | }
277 |
278 | // Setup timezone (after logger is configured)
279 | tz := os.Getenv("TZ")
280 | if tz != "" {
281 | loc, err := time.LoadLocation(tz)
282 | if err != nil {
283 | log.Warn().Err(err).Msgf("It was not possible to define TZ=%q, using UTC", tz)
284 | } else {
285 | time.Local = loc
286 | log.Info().Str("TZ", tz).Msg("Timezone defined")
287 | }
288 | }
289 |
290 | if *adminToken == "" {
291 | if v := os.Getenv("WUZAPI_ADMIN_TOKEN"); v != "" {
292 | *adminToken = v
293 | } else {
294 | // Generate a random token if none provided
295 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
296 | b := make([]byte, 32)
297 | for i := range b {
298 | b[i] = charset[rand.Intn(len(charset))]
299 | }
300 | *adminToken = string(b)
301 | log.Warn().Str("admin_token", *adminToken).Msg("No admin token provided, generated a random one")
302 | }
303 | }
304 |
305 | if *globalEncryptionKey == "" {
306 | if v := os.Getenv("WUZAPI_GLOBAL_ENCRYPTION_KEY"); v != "" {
307 | *globalEncryptionKey = v
308 | log.Info().Msg("Encryption key loaded from environment variable")
309 | } else {
310 | // Generate a random key if none provided
311 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
312 | b := make([]byte, 32)
313 | for i := range b {
314 | b[i] = charset[rand.Intn(len(charset))]
315 | }
316 | *globalEncryptionKey = string(b)
317 | log.Warn().Str("global_encryption_key", *globalEncryptionKey).Msg("No WUZAPI_GLOBAL_ENCRYPTION_KEY provided, generated a random one. " +
318 | "SAVE THIS KEY TO YOUR .ENV FILE OR ALL ENCRYPTED DATA WILL BE LOST ON RESTART!")
319 | }
320 | }
321 |
322 | // Check for global webhook in environment variable
323 | if *globalWebhook == "" {
324 | if v := os.Getenv("WUZAPI_GLOBAL_WEBHOOK"); v != "" {
325 | *globalWebhook = v
326 | log.Info().Str("global_webhook", v).Msg("Global webhook configured from environment variable")
327 | }
328 | } else {
329 | log.Info().Str("global_webhook", *globalWebhook).Msg("Global webhook configured from command line")
330 | }
331 |
332 | // Check for global HMAC key in environment variable
333 | if *globalHMACKey == "" {
334 | if v := os.Getenv("WUZAPI_GLOBAL_HMAC_KEY"); v != "" {
335 | *globalHMACKey = v
336 | log.Info().Msg("Global HMAC key configured from environment variable")
337 | } else {
338 | // Generate a random key if none provided
339 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
340 | b := make([]byte, 32)
341 | for i := range b {
342 | b[i] = charset[rand.Intn(len(charset))]
343 | }
344 | *globalHMACKey = string(b)
345 | log.Warn().Str("global_hmac_key", *globalHMACKey).Msg("No WUZAPI_GLOBAL_HMAC_KEY provided, generated a random one")
346 | }
347 |
348 | } else {
349 | log.Info().Msg("Global HMAC key configured from command line")
350 | }
351 |
352 | globalHMACKeyEncrypted, err = encryptHMACKey(*globalHMACKey)
353 | if err != nil {
354 | log.Error().Err(err).Msg("Failed to encrypt global HMAC key")
355 | } else {
356 | log.Info().Msg("Global HMAC key encrypted successfully")
357 | }
358 |
359 | InitRabbitMQ()
360 |
361 | ex, err := os.Executable()
362 | if err != nil {
363 | log.Fatal().Err(err).Msg("Failed to get executable path")
364 | panic(err)
365 | }
366 | exPath := filepath.Dir(ex)
367 |
368 | db, err := InitializeDatabase(exPath, *dataDir)
369 | if err != nil {
370 | log.Fatal().Err(err).Msg("Failed to initialize database")
371 | os.Exit(1)
372 | }
373 | // Defer cleanup of the database connection
374 | defer func() {
375 | if err := db.Close(); err != nil {
376 | log.Error().Err(err).Msg("Failed to close database connection")
377 | }
378 | }()
379 |
380 | // Initialize the schema
381 | if err = initializeSchema(db); err != nil {
382 | log.Fatal().Err(err).Msg("Failed to initialize schema")
383 | // Perform cleanup before exiting
384 | if err := db.Close(); err != nil {
385 | log.Error().Err(err).Msg("Failed to close database connection during cleanup")
386 | }
387 | os.Exit(1)
388 | }
389 |
390 | var dbLog waLog.Logger
391 | if *waDebug != "" {
392 | dbLog = waLog.Stdout("Database", *waDebug, *colorOutput)
393 | }
394 |
395 | // Get database configuration
396 | config := getDatabaseConfig(exPath, *dataDir)
397 | var storeConnStr string
398 | if config.Type == "postgres" {
399 | storeConnStr = fmt.Sprintf(
400 | "user=%s password=%s dbname=%s host=%s port=%s sslmode=%s",
401 | config.User, config.Password, config.Name, config.Host, config.Port, config.SSLMode,
402 | )
403 | container, err = sqlstore.New(context.Background(), "postgres", storeConnStr, dbLog)
404 | } else {
405 | storeConnStr = "file:" + filepath.Join(config.Path, "main.db") + "?_pragma=foreign_keys(1)&_busy_timeout=3000"
406 | container, err = sqlstore.New(context.Background(), "sqlite", storeConnStr, dbLog)
407 | }
408 |
409 | if err != nil {
410 | log.Fatal().Err(err).Msg("Error creating sqlstore")
411 | os.Exit(1)
412 | }
413 |
414 | serverMode := HTTP
415 | if *mode == "stdio" {
416 | serverMode = Stdio
417 | }
418 |
419 | s := &server{
420 | router: mux.NewRouter(),
421 | db: db,
422 | exPath: exPath,
423 | mode: serverMode,
424 | }
425 | s.routes()
426 |
427 | s.connectOnStartup()
428 |
429 | if serverMode == Stdio {
430 | startStdioMode(s)
431 | } else {
432 | startHTTPMode(s)
433 | }
434 | }
435 |
436 | func startHTTPMode(s *server) {
437 | srv := &http.Server{
438 | Addr: *address + ":" + *port,
439 | Handler: s.router,
440 | ReadHeaderTimeout: 20 * time.Second,
441 | ReadTimeout: 60 * time.Second,
442 | WriteTimeout: 120 * time.Second,
443 | IdleTimeout: 180 * time.Second,
444 | }
445 |
446 | done := make(chan os.Signal, 1)
447 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
448 |
449 | var once sync.Once
450 |
451 | // Wait for signals in a separate goroutine
452 | go func() {
453 | for {
454 | <-done
455 | once.Do(func() {
456 | log.Warn().Msg("Stopping server...")
457 |
458 | // Graceful shutdown logic
459 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
460 | defer cancel()
461 |
462 | if err := srv.Shutdown(ctx); err != nil {
463 | log.Error().Err(err).Msg("Failed to stop server")
464 | os.Exit(1)
465 | }
466 |
467 | log.Info().Msg("Server Exited Properly")
468 | os.Exit(0)
469 | })
470 | }
471 | }()
472 |
473 | go func() {
474 | if *sslcert != "" {
475 |
476 | if *sslcert != "" && *sslprivkey != "" {
477 | if _, err := os.Stat(*sslcert); os.IsNotExist(err) {
478 | log.Fatal().Err(err).Msg("SSL certificate file does not exist")
479 | }
480 | if _, err := os.Stat(*sslprivkey); os.IsNotExist(err) {
481 | log.Fatal().Err(err).Msg("SSL private key file does not exist")
482 | }
483 | }
484 | if err := srv.ListenAndServeTLS(*sslcert, *sslprivkey); err != nil && err != http.ErrServerClosed {
485 | log.Fatal().Err(err).Msg("HTTPS server failed to start")
486 | }
487 | } else {
488 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
489 | log.Fatal().Err(err).Msg("HTTP server failed to start")
490 | }
491 | }
492 | }()
493 | log.Info().Str("address", *address).Str("port", *port).Msg("Server started. Waiting for connections...")
494 | select {}
495 | }
496 |
497 | func startStdioMode(s *server) {
498 | stdioServer := NewStdioServer(s)
499 | if err := stdioServer.Start(); err != nil {
500 | log.Error().Err(err).Msg("Stdio server error")
501 | os.Exit(1)
502 | }
503 | log.Info().Msg("Stdio server exited properly")
504 | }
505 |
--------------------------------------------------------------------------------
/static/github-markdown-css/code-navigation-banner-illo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/stdio.go:
--------------------------------------------------------------------------------
1 | // Package main provides a JSON-RPC 2.0 server over stdin/stdout.
2 | //
3 | // This file implements a stdio-based JSON-RPC 2.0 interface that bridges
4 | // to the existing HTTP API handlers. It enables programmatic access to
5 | // wuzapi functionality through standard input/output, making it suitable
6 | // for use as a subprocess or in headless environments.
7 | //
8 | // The implementation:
9 | // - Reads newline-delimited JSON-RPC 2.0 requests from stdin
10 | // - Routes requests to existing HTTP handlers via httptest
11 | // - Writes JSON-RPC 2.0 responses to stdout
12 | // - Supports both notification and request/response patterns
13 | //
14 | // JSON-RPC methods map directly to HTTP endpoints (e.g., "user.login"
15 | // maps to POST /user/login). See JSON-RPC-API.md for available methods.
16 | //
17 | // Author: Alvaro Ramirez https://xenodium.com
18 | package main
19 |
20 | import (
21 | "bufio"
22 | "bytes"
23 | "encoding/json"
24 | "fmt"
25 | "io"
26 | "net/http/httptest"
27 | "os"
28 |
29 | "github.com/rs/zerolog/log"
30 | )
31 |
32 | // ID represents a JSON-RPC 2.0 request/response identifier
33 | // It can be either a string or number per the spec
34 | type ID struct {
35 | Num uint64
36 | Str string
37 | IsString bool // true if ID is a string, false if numeric
38 | IsSet bool // true if ID was present in JSON (not omitted)
39 | }
40 |
41 | // MarshalJSON implements json.Marshaler
42 | func (id ID) MarshalJSON() ([]byte, error) {
43 | if !id.IsSet {
44 | return []byte("null"), nil
45 | }
46 | if id.IsString {
47 | return json.Marshal(id.Str)
48 | }
49 | return json.Marshal(id.Num)
50 | }
51 |
52 | // UnmarshalJSON implements json.Unmarshaler
53 | func (id *ID) UnmarshalJSON(data []byte) error {
54 | // Try numeric first
55 | var num uint64
56 | if err := json.Unmarshal(data, &num); err == nil {
57 | *id = ID{Num: num, IsString: false, IsSet: true}
58 | return nil
59 | }
60 | // Fall back to string
61 | var str string
62 | if err := json.Unmarshal(data, &str); err != nil {
63 | return err
64 | }
65 | *id = ID{Str: str, IsString: true, IsSet: true}
66 | return nil
67 | }
68 |
69 | // String returns the ID as a string for logging/display
70 | func (id ID) String() string {
71 | if id.IsString {
72 | return id.Str
73 | }
74 | return fmt.Sprintf("%d", id.Num)
75 | }
76 |
77 | // jsonRpcRequest represents an incoming JSON request from stdin
78 | type jsonRpcRequest struct {
79 | ID ID `json:"id"`
80 | Method string `json:"method"`
81 | Params map[string]interface{} `json:"params,omitempty"`
82 | }
83 |
84 | // jsonRpcResponse represents an outgoing JSON response to stdout
85 | // Follows JSON-RPC 2.0 specification
86 | type jsonRpcResponse struct {
87 | JSONRPC string `json:"jsonrpc"`
88 | ID ID `json:"id"`
89 | Result *jsonResult `json:"result,omitempty"`
90 | Error *rpcError `json:"error,omitempty"`
91 | }
92 |
93 | // jsonResult wraps the result value so we can distinguish between
94 | // "no result" (nil pointer, omitted) and "null result" (pointer to nil)
95 | type jsonResult struct {
96 | Value interface{}
97 | }
98 |
99 | // MarshalJSON makes jsonResult marshal as its wrapped value
100 | func (r *jsonResult) MarshalJSON() ([]byte, error) {
101 | return json.Marshal(r.Value)
102 | }
103 |
104 | // rpcError represents a JSON-RPC 2.0 error object
105 | type rpcError struct {
106 | Code int `json:"code"`
107 | Message string `json:"message"`
108 | }
109 |
110 | // stdioServer handles stdin/stdout JSON-based API by wrapping HTTP handlers
111 | type stdioServer struct {
112 | server *server
113 | stdin io.Reader
114 | stdout io.Writer
115 | }
116 |
117 | // NewStdioServer creates a new stdio server instance
118 | func NewStdioServer(s *server) *stdioServer {
119 | return &stdioServer{
120 | server: s,
121 | stdin: os.Stdin,
122 | stdout: os.Stdout,
123 | }
124 | }
125 |
126 | // newStdioServerWithIO creates a stdio server with custom IO streams (for testing)
127 | func newStdioServerWithIO(s *server, stdin io.Reader, stdout io.Writer) *stdioServer {
128 | return &stdioServer{
129 | server: s,
130 | stdin: stdin,
131 | stdout: stdout,
132 | }
133 | }
134 |
135 | func (ss *stdioServer) Start() error {
136 | log.Info().Msg("Starting stdio mode - reading JSON requests from stdin")
137 |
138 | scanner := bufio.NewScanner(ss.stdin)
139 |
140 | const maxCapacity = 512 * 1024 // 512KB
141 | buf := make([]byte, maxCapacity)
142 | scanner.Buffer(buf, maxCapacity)
143 |
144 | for scanner.Scan() {
145 | line := scanner.Bytes()
146 | if len(line) == 0 {
147 | continue // Skip empty lines
148 | }
149 | ss.handleRequest(line)
150 | }
151 |
152 | // Scanner stopped, check why
153 | if err := scanner.Err(); err != nil {
154 | log.Error().Err(err).Msg("Error reading from stdin")
155 | return err
156 | }
157 |
158 | log.Info().Msg("EOF reached on stdin, shutting down")
159 | return nil
160 | }
161 |
162 | func (ss *stdioServer) handleRequest(requestBytes []byte) {
163 | var req jsonRpcRequest
164 | if err := json.Unmarshal(requestBytes, &req); err != nil {
165 | ss.sendError(ID{}, 400, fmt.Sprintf("invalid JSON request: %v", err))
166 | return
167 | }
168 | // ID is required - check if it was present in the JSON
169 | if !req.ID.IsSet {
170 | ss.sendError(ID{}, 400, "missing request id")
171 | return
172 | }
173 | if req.Method == "" {
174 | ss.sendError(req.ID, 400, "missing method")
175 | return
176 | }
177 | log.Info().
178 | Str("id", req.ID.String()).
179 | Str("method", req.Method).
180 | Msg("Processing stdio request")
181 | ss.routeRequest(&req)
182 | }
183 |
184 | // routeRequest dispatches the request to the appropriate HTTP handler
185 | // getUserIdParam extracts and validates userId from request params
186 | func (ss *stdioServer) getUserIdParam(req *jsonRpcRequest) (string, bool) {
187 | userId, ok := req.Params["userId"].(string)
188 | if !ok || userId == "" {
189 | ss.sendError(req.ID, 400, "missing or invalid userId parameter")
190 | return "", false
191 | }
192 | return userId, true
193 | }
194 |
195 | func (ss *stdioServer) routeRequest(req *jsonRpcRequest) {
196 | // Map stdio method to HTTP route and method
197 | var httpMethod, httpPath string
198 |
199 | switch req.Method {
200 | case "health":
201 | httpMethod = "GET"
202 | httpPath = "/health"
203 |
204 | // Admin user management
205 | case "admin.users.add":
206 | httpMethod = "POST"
207 | httpPath = "/admin/users"
208 | case "admin.users.list":
209 | httpMethod = "GET"
210 | httpPath = "/admin/users"
211 | case "admin.users.get":
212 | httpMethod = "GET"
213 | userId, ok := ss.getUserIdParam(req)
214 | if !ok {
215 | // Error sent by getUserIdParam.
216 | return
217 | }
218 | httpPath = "/admin/users/" + userId
219 | case "admin.users.delete":
220 | httpMethod = "DELETE"
221 | userId, ok := ss.getUserIdParam(req)
222 | if !ok {
223 | // Error sent by getUserIdParam.
224 | return
225 | }
226 | httpPath = "/admin/users/" + userId
227 | case "admin.users.edit":
228 | httpMethod = "PUT"
229 | userId, ok := ss.getUserIdParam(req)
230 | if !ok {
231 | // Error sent by getUserIdParam.
232 | return
233 | }
234 | httpPath = "/admin/users/" + userId
235 | case "admin.users.delete.full":
236 | httpMethod = "DELETE"
237 | userId, ok := ss.getUserIdParam(req)
238 | if !ok {
239 | // Error sent by getUserIdParam.
240 | return
241 | }
242 | httpPath = "/admin/users/" + userId + "/full"
243 |
244 | // Session management
245 | case "session.connect":
246 | httpMethod = "POST"
247 | httpPath = "/session/connect"
248 | case "session.qr":
249 | httpMethod = "GET"
250 | httpPath = "/session/qr"
251 | case "session.status":
252 | httpMethod = "GET"
253 | httpPath = "/session/status"
254 | case "session.disconnect":
255 | httpMethod = "POST"
256 | httpPath = "/session/disconnect"
257 | case "session.logout":
258 | httpMethod = "POST"
259 | httpPath = "/session/logout"
260 | case "session.pairphone":
261 | httpMethod = "POST"
262 | httpPath = "/session/pairphone"
263 | case "session.history":
264 | httpMethod = "GET"
265 | httpPath = "/session/history"
266 | case "session.history.set":
267 | httpMethod = "POST"
268 | httpPath = "/session/history"
269 | case "session.proxy":
270 | httpMethod = "POST"
271 | httpPath = "/session/proxy"
272 | case "session.hmac.config":
273 | httpMethod = "POST"
274 | httpPath = "/session/hmac/config"
275 | case "session.hmac.config.get":
276 | httpMethod = "GET"
277 | httpPath = "/session/hmac/config"
278 | case "session.hmac.config.delete":
279 | httpMethod = "DELETE"
280 | httpPath = "/session/hmac/config"
281 |
282 | // Messaging
283 | case "chat.send.text":
284 | httpMethod = "POST"
285 | httpPath = "/chat/send/text"
286 | case "chat.send.image":
287 | httpMethod = "POST"
288 | httpPath = "/chat/send/image"
289 | case "chat.send.video":
290 | httpMethod = "POST"
291 | httpPath = "/chat/send/video"
292 | case "chat.send.document":
293 | httpMethod = "POST"
294 | httpPath = "/chat/send/document"
295 | case "chat.send.audio":
296 | httpMethod = "POST"
297 | httpPath = "/chat/send/audio"
298 | case "chat.send.sticker":
299 | httpMethod = "POST"
300 | httpPath = "/chat/send/sticker"
301 | case "chat.send.location":
302 | httpMethod = "POST"
303 | httpPath = "/chat/send/location"
304 | case "chat.send.contact":
305 | httpMethod = "POST"
306 | httpPath = "/chat/send/contact"
307 | case "chat.send.poll":
308 | httpMethod = "POST"
309 | httpPath = "/chat/send/poll"
310 | case "chat.send.buttons":
311 | httpMethod = "POST"
312 | httpPath = "/chat/send/buttons"
313 | case "chat.send.list":
314 | httpMethod = "POST"
315 | httpPath = "/chat/send/list"
316 | case "chat.send.edit":
317 | httpMethod = "POST"
318 | httpPath = "/chat/send/edit"
319 | case "chat.delete":
320 | httpMethod = "POST"
321 | httpPath = "/chat/delete"
322 | case "chat.react":
323 | httpMethod = "POST"
324 | httpPath = "/chat/react"
325 | case "chat.archive":
326 | httpMethod = "POST"
327 | httpPath = "/chat/archive"
328 | case "chat.presence":
329 | httpMethod = "POST"
330 | httpPath = "/chat/presence"
331 | case "chat.markread":
332 | httpMethod = "POST"
333 | httpPath = "/chat/markread"
334 | case "chat.request-unavailable-message":
335 | httpMethod = "POST"
336 | httpPath = "/chat/request-unavailable-message"
337 | case "chat.download.image":
338 | httpMethod = "POST"
339 | httpPath = "/chat/downloadimage"
340 | case "chat.download.video":
341 | httpMethod = "POST"
342 | httpPath = "/chat/downloadvideo"
343 | case "chat.download.audio":
344 | httpMethod = "POST"
345 | httpPath = "/chat/downloadaudio"
346 | case "chat.download.document":
347 | httpMethod = "POST"
348 | httpPath = "/chat/downloaddocument"
349 | case "chat.history":
350 | httpMethod = "GET"
351 | chatJID, ok := req.Params["chat_jid"].(string)
352 | if !ok || chatJID == "" {
353 | ss.sendError(req.ID, 400, "missing or invalid chat_jid parameter")
354 | return
355 | }
356 | httpPath = "/chat/history?chat_jid=" + chatJID
357 | // Add optional limit parameter
358 | if limit, ok := req.Params["limit"].(float64); ok {
359 | httpPath += fmt.Sprintf("&limit=%d", int(limit))
360 | }
361 |
362 | // User info
363 | case "user.contacts":
364 | httpMethod = "GET"
365 | httpPath = "/user/contacts"
366 | case "user.presence":
367 | httpMethod = "POST"
368 | httpPath = "/user/presence"
369 | case "user.info":
370 | httpMethod = "POST"
371 | httpPath = "/user/info"
372 | case "user.check":
373 | httpMethod = "POST"
374 | httpPath = "/user/check"
375 | case "user.avatar":
376 | httpMethod = "POST"
377 | httpPath = "/user/avatar"
378 | case "user.lid":
379 | httpMethod = "GET"
380 | jid, ok := req.Params["jid"].(string)
381 | if !ok || jid == "" {
382 | ss.sendError(req.ID, 400, "missing or invalid jid parameter")
383 | return
384 | }
385 | httpPath = "/user/lid/" + jid
386 |
387 | // Status
388 | case "status.set.text":
389 | httpMethod = "POST"
390 | httpPath = "/status/set/text"
391 |
392 | // Calls
393 | case "call.reject":
394 | httpMethod = "POST"
395 | httpPath = "/call/reject"
396 |
397 | // Group management
398 | case "group.list":
399 | httpMethod = "GET"
400 | httpPath = "/group/list"
401 | case "group.create":
402 | httpMethod = "POST"
403 | httpPath = "/group/create"
404 | case "group.info":
405 | httpMethod = "GET"
406 | httpPath = "/group/info"
407 | case "group.invitelink":
408 | httpMethod = "GET"
409 | httpPath = "/group/invitelink"
410 | case "group.photo":
411 | httpMethod = "POST"
412 | httpPath = "/group/photo"
413 | case "group.photo.remove":
414 | httpMethod = "POST"
415 | httpPath = "/group/photo/remove"
416 | case "group.leave":
417 | httpMethod = "POST"
418 | httpPath = "/group/leave"
419 | case "group.name":
420 | httpMethod = "POST"
421 | httpPath = "/group/name"
422 | case "group.topic":
423 | httpMethod = "POST"
424 | httpPath = "/group/topic"
425 | case "group.announce":
426 | httpMethod = "POST"
427 | httpPath = "/group/announce"
428 | case "group.locked":
429 | httpMethod = "POST"
430 | httpPath = "/group/locked"
431 | case "group.ephemeral":
432 | httpMethod = "POST"
433 | httpPath = "/group/ephemeral"
434 | case "group.join":
435 | httpMethod = "POST"
436 | httpPath = "/group/join"
437 | case "group.inviteinfo":
438 | httpMethod = "POST"
439 | httpPath = "/group/inviteinfo"
440 | case "group.updateparticipants":
441 | httpMethod = "POST"
442 | httpPath = "/group/updateparticipants"
443 |
444 | // Newsletter
445 | case "newsletter.list":
446 | httpMethod = "GET"
447 | httpPath = "/newsletter/list"
448 |
449 | // Webhook management
450 | case "webhook.get":
451 | httpMethod = "GET"
452 | httpPath = "/webhook"
453 | case "webhook.set":
454 | httpMethod = "POST"
455 | httpPath = "/webhook"
456 | case "webhook.update":
457 | httpMethod = "PUT"
458 | httpPath = "/webhook"
459 | case "webhook.delete":
460 | httpMethod = "DELETE"
461 | httpPath = "/webhook"
462 |
463 | default:
464 | ss.sendError(req.ID, 404, fmt.Sprintf("unknown method: %s", req.Method))
465 | return
466 | }
467 | ss.executeHTTPHandler(req, httpMethod, httpPath)
468 | }
469 |
470 | // executeHTTPHandler wraps the existing HTTP handler and adapts it for stdio
471 | func (ss *stdioServer) executeHTTPHandler(req *jsonRpcRequest, httpMethod, httpPath string) {
472 | // Create a mock HTTP request
473 | var body io.Reader
474 | if req.Params != nil && len(req.Params) > 0 {
475 | jsonParams, err := json.Marshal(req.Params)
476 | if err != nil {
477 | ss.sendError(req.ID, 400, fmt.Sprintf("invalid params: %v", err))
478 | return
479 | }
480 | body = bytes.NewReader(jsonParams)
481 | }
482 |
483 | httpReq := httptest.NewRequest(httpMethod, httpPath, body)
484 | httpReq.Header.Set("Content-Type", "application/json")
485 |
486 | // Set user token header (for user authentication)
487 | if token, ok := req.Params["token"].(string); ok {
488 | httpReq.Header.Set("token", token)
489 | }
490 | // Set admin token header (for admin authentication)
491 | if adminToken, ok := req.Params["adminToken"].(string); ok {
492 | httpReq.Header.Set("Authorization", adminToken)
493 | }
494 |
495 | recorder := httptest.NewRecorder()
496 | ss.server.router.ServeHTTP(recorder, httpReq)
497 | ss.convertHTTPResponse(req.ID, recorder)
498 | }
499 |
500 | // convertHTTPResponse converts an HTTP response to a stdio response
501 | func (ss *stdioServer) convertHTTPResponse(requestID ID, recorder *httptest.ResponseRecorder) {
502 | statusCode := recorder.Code
503 | responseBody := recorder.Body.Bytes()
504 |
505 | var responseData interface{}
506 | if len(responseBody) > 0 {
507 | if err := json.Unmarshal(responseBody, &responseData); err != nil {
508 | // If it's not JSON, just use the raw string
509 | responseData = string(responseBody)
510 | }
511 | }
512 |
513 | success := statusCode >= 200 && statusCode < 300
514 |
515 | if respMap, ok := responseData.(map[string]interface{}); ok {
516 | // If it's already in wuzapi format, extract the data/error
517 | if data, hasData := respMap["data"]; hasData {
518 | ss.sendSuccess(requestID, statusCode, data)
519 | return
520 | }
521 | if errMsg, hasError := respMap["error"]; hasError {
522 | if errStr, ok := errMsg.(string); ok {
523 | ss.sendError(requestID, statusCode, errStr)
524 | return
525 | }
526 | }
527 | ss.sendSuccess(requestID, statusCode, respMap)
528 | return
529 | }
530 |
531 | // For non-JSON or simple responses
532 | if success {
533 | ss.sendSuccess(requestID, statusCode, responseData)
534 | } else {
535 | errorMsg := "request failed"
536 | if str, ok := responseData.(string); ok && str != "" {
537 | errorMsg = str
538 | }
539 | ss.sendError(requestID, statusCode, errorMsg)
540 | }
541 | }
542 |
543 | func (ss *stdioServer) sendSuccess(id ID, code int, data interface{}) {
544 | response := jsonRpcResponse{
545 | JSONRPC: "2.0",
546 | ID: id,
547 | Result: &jsonResult{Value: data},
548 | }
549 | ss.writeResponse(response)
550 | }
551 |
552 | func (ss *stdioServer) sendError(id ID, code int, errorMsg string) {
553 | response := jsonRpcResponse{
554 | JSONRPC: "2.0",
555 | ID: id,
556 | Error: &rpcError{
557 | Code: code,
558 | Message: errorMsg,
559 | },
560 | }
561 | ss.writeResponse(response)
562 | }
563 |
564 | func (ss *stdioServer) writeResponse(response jsonRpcResponse) {
565 | // Marshalled response as single line
566 | responseBytes, err := json.Marshal(response)
567 | if err != nil {
568 | log.Error().Err(err).Msg("Failed to marshal response")
569 | fallback := jsonRpcResponse{
570 | JSONRPC: "2.0",
571 | ID: response.ID,
572 | Error: &rpcError{
573 | Code: -32603,
574 | Message: "Internal error: failed to marshal response",
575 | },
576 | }
577 | responseBytes, err = json.Marshal(fallback)
578 | if err != nil {
579 | log.Error().Err(err).Msg("Failed to marshal fallback response")
580 | return
581 | }
582 | }
583 |
584 | // Write to stdout with newline
585 | fmt.Fprintf(ss.stdout, "%s\n", string(responseBytes))
586 |
587 | // Log with appropriate fields based on response type
588 | logEvent := log.Debug().Str("id", response.ID.String())
589 | if response.Error != nil {
590 | logEvent.Bool("success", false).Int("code", response.Error.Code).Str("error", response.Error.Message)
591 | } else {
592 | logEvent.Bool("success", true)
593 | }
594 | logEvent.Msg("Sent stdio response")
595 | }
596 |
597 | // jsonRpcNotification represents a one-way notification (no id, no response expected)
598 | // Follows JSON-RPC 2.0 specification
599 | type jsonRpcNotification struct {
600 | JSONRPC string `json:"jsonrpc"`
601 | Method string `json:"method"`
602 | Params map[string]interface{} `json:"params,omitempty"`
603 | }
604 |
605 | // SendNotification sends a JSON-RPC notification to stdout (webhooks in stdio mode)
606 | // This is thread-safe - os.Stdout writes are atomic at the OS level
607 | func (s *server) SendNotification(method string, params map[string]interface{}) {
608 | if s.mode != Stdio {
609 | return
610 | }
611 |
612 | notification := jsonRpcNotification{
613 | JSONRPC: "2.0",
614 | Method: method,
615 | Params: params,
616 | }
617 |
618 | notificationBytes, err := json.Marshal(notification)
619 | if err != nil {
620 | log.Error().Err(err).Msg("Failed to marshal notification")
621 | return
622 | }
623 |
624 | fmt.Fprintf(os.Stdout, "%s\n", string(notificationBytes))
625 |
626 | log.Debug().
627 | Str("method", method).
628 | Msg("Sent stdio notification")
629 | }
630 |
--------------------------------------------------------------------------------
/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | WUZAPI - REST API for WhatsApp
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
40 |
41 |
42 |
43 |
44 |
REST API for WhatsApp
45 |
WUZAPI is an implementation of the whatsmeow library as a simple RESTful API service, with support for multiple devices and concurrent sessions.
46 |
51 |
52 |
53 |
54 | Learn more
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
Main Features
63 |
WUZAPI offers a complete API to interact with WhatsApp efficiently without using heavy resources like Puppeteer or Android emulators.
64 |
65 |
66 |
67 |
68 |
69 |
70 |
High Performance
71 |
Direct communication with WhatsApp servers via websocket, resulting in lower memory and CPU usage.
72 |
73 |
74 |
75 |
76 |
77 |
Multiple Sessions
78 |
Support for multiple devices and users simultaneously on the same instance.
79 |
80 |
81 |
82 |
83 |
84 |
Rich Messages
85 |
Send text, images, audio, documents, videos, stickers, location, and contacts.
86 |
87 |
88 |
89 |
90 |
91 |
Webhooks
92 |
Configure webhooks to receive real-time notifications of events and messages.
93 |
94 |
95 |
96 |
97 |
98 |
User Verification
99 |
Check if phone numbers have WhatsApp and get profile information.
100 |
101 |
102 |
103 |
104 |
105 |
Simple Authentication
106 |
Token system for easy and secure user authentication.
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
API Endpoints
116 |
WUZAPI offers several endpoints to interact with WhatsApp, organized into functional categories.
117 |
118 |
119 |
120 |
Session
121 |
Manage WhatsApp sessions with ease.
122 |
123 | Connect to WhatsApp
124 | Disconnect, Logout
125 | Check connection status
126 | Get QR Code for scanning
127 | Pair by Phone Number
128 | Proxy configuration
129 |
130 |
131 |
132 |
Chat
133 |
Interact with chats and messages.
134 |
135 | Mark messages as read
136 | Delete messages
137 | Send reactions to messages
138 | Set presence (typing/recording)
139 | Download images
140 | Download videos
141 | Download documents
142 |
143 |
144 |
145 |
Messages
146 |
Send different types of messages.
147 |
148 | Text, images, audio
149 | Documents, videos, stickers
150 | Location, contacts
151 | Templates with buttons
152 |
153 |
154 |
155 |
Users
156 |
Get information about users.
157 |
158 | Check if numbers have WhatsApp
159 | Get profile information
160 | Get avatar/profile picture
161 | List contacts
162 | Set global presence
163 |
164 |
165 |
166 |
Groups
167 |
Manage WhatsApp groups.
168 |
169 | List subscribed groups
170 | Get group information
171 | Get invite link
172 | Change group name
173 | Change group picture
174 |
175 |
176 |
177 |
Webhooks
178 |
Configure notifications for events.
179 |
180 | Set webhook URL
181 | Subscribe to specific events
182 | Update configuration
183 | Remove webhook
184 |
185 |
186 |
187 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
Interactive Demo
197 |
See how easy it is to use the WUZAPI API to interact with WhatsApp.
198 |
199 |
200 |
201 |
202 |
209 |
210 |
211 |
212 |
213 |
214 |
215 | Send Message
216 | Send Media
217 | Configure Webhook
218 |
219 |
220 |
221 |
222 |
223 |
224 |
Important Warning
225 |
Using this software in violation of WhatsApp's Terms of Service may result in your number being banned . Be very careful, do not use it for SPAM or anything similar. Use at your own risk. If you need to develop something with commercial interest, contact a global WhatsApp solution provider and sign up for the WhatsApp Business API service.
226 |
227 |
228 |
229 |
230 |
231 |
How to Use
232 |
Instructions to start using WUZAPI quickly.
233 |
234 |
235 |
236 |
237 |
Prerequisites
238 |
To run WUZAPI, you need:
239 |
240 | Go (Go Programming Language)
241 | Docker (optional, for containerization)
242 | Postgres (optional, can work standalone using SQLite)
243 |
244 |
245 |
246 |
247 |
Compilation
248 |
Compile the project with the following command:
249 |
252 |
253 |
254 |
255 |
Configuration
256 |
WuzAPI uses a .env file for configuration. Here are the required settings:
257 |
For PostgresSQL
258 |
259 |
WUZAPI_ADMIN_TOKEN=your_admin_token_here
260 | USER=wuzapi
261 | PASSWORD=wuzapi
262 | NAME=wuzapi
263 | HOST=localhost
264 | PORT=5432
265 | America/New_York
266 |
267 |
For SQLite
268 |
269 |
WUZAPI_ADMIN_TOKEN=your_admin_token_here
270 | TZ=America/New_York
271 |
272 |
273 |
274 |
275 |
Execution
276 |
By default, the service will start on port 8080. You can change the behavior with the following parameters:
277 |
278 | -address: defines the IP address to bind the server (default 0.0.0.0)
279 | -port: defines the port number (default 8080)
280 | -logtype: format for logs, console (default) or json
281 | -wadebug: enables whatsmeow debug, INFO or DEBUG levels are supported
282 | -sslcertificate: SSL Certificate File
283 | -sslprivatekey: SSL Private Key File
284 | -skipmedia: Do not automatically download media from received messages
285 | -osname: Connection OSName in Whatsapp (default "Mac OS 10")
286 | -admintoken: Security Token to authorize admin actions (list/create/remove users)
287 |
288 |
Example:
289 |
290 |
./wuzapi -logtype json
291 |
292 |
293 |
294 |
295 |
Creating Users
296 |
To open sessions, you first need to create a user and define an authentication token for it. You can do this login into the Dashboard , or using the API directly:
297 |
298 |
curl -X POST http://localhost:8080/admin/users \
299 | -H "Authorization: $WUZAPI_ADMIN_TOKEN" \
300 | -H "Content-Type: application/json" \
301 | -d '{"name": "John", "token": "Z1234ABCCXD"}'
302 |
303 |
Once users are created, you can communicate with the API by passing the Token header as a simple authentication method. You can have multiple users (different numbers) on the same server. For every user you will need to Connect to whatsapp and then either scan a QR Code or Pair via phone number.
304 |
305 |
306 |
307 |
Web Resources
308 |
The daemon also serves some static web files, useful for development/testing that you can load with your browser:
309 |
310 | A Swagger API reference at /api
311 | An example web page to connect and scan QR codes at /login (where you will need to pass ?token=1234ABCD)
312 | A management dashboard at /dashboard
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
326 |
327 |
328 |
Scan to Donate
329 |
330 |
Scan the QR code above with your banking app or digital wallet.
331 |
332 |
333 |
337 |
Buy Me a Coffee
338 |
Or make a donation via PayPal:
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 |
350 |
378 |
379 |
Copyright © 2025 Nicolás Gudiño and contributors
380 |
License MIT
381 |
This code is not affiliated with, authorized, maintained, sponsored, or endorsed by WhatsApp or any of its affiliates or subsidiaries.
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 |
391 |
392 |
393 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
3 | github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
4 | github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
5 | github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
6 | github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
7 | github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
8 | github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
9 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
10 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
11 | github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
12 | github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
13 | github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
14 | github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
15 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
16 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
17 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
18 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
19 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
20 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
21 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
22 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
23 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
24 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
25 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
26 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
27 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 h1:BCG7DCXEXpNCcpwCxg1oi9pkJWH2+eZzTn9MY56MbVw=
28 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
30 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
31 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
32 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
33 | github.com/aws/aws-sdk-go-v2/service/s3 v1.79.4 h1:4yxno6bNHkekkfqG/a1nz/gC2gBwhJSojV1+oTE7K+4=
34 | github.com/aws/aws-sdk-go-v2/service/s3 v1.79.4/go.mod h1:qbn305Je/IofWBJ4bJz/Q7pDEtnnoInw/dGt71v6rHE=
35 | github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
36 | github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
37 | github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
38 | github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
39 | github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
40 | github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
41 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
42 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
43 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
44 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
45 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
46 | github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
47 | github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
48 | github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
49 | github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
50 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
51 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
52 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
53 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
54 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
55 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
56 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
57 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
58 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
59 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
60 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
61 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
62 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
63 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
64 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
65 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
66 | github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
67 | github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
68 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
69 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
70 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
71 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
72 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
73 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
74 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
75 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
76 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
77 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
78 | github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
79 | github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
80 | github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
81 | github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
82 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
83 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
84 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
85 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
86 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
87 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
88 | github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 h1:QTvNkZ5ylY0PGgA+Lih+GdboMLY/G9SEGLMEGVjTVA4=
89 | github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
90 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
91 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
92 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
93 | github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
94 | github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
95 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
96 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
97 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
98 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
99 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
100 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
101 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
102 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
103 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
104 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
105 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
106 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
107 | github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k=
108 | github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
109 | github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
110 | github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
111 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
112 | go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
113 | go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
114 | go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU=
115 | go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ=
116 | go.mau.fi/whatsmeow v0.0.0-20251120135021-071293c6b9f0 h1:ZDDLaG7VZ3peRWOsJMCxIhoeYuRGc937DzoUtnShqd0=
117 | go.mau.fi/whatsmeow v0.0.0-20251120135021-071293c6b9f0/go.mod h1:5aYaEa3FF5e5XWsA8Xa80ttUXZvb6HyaBGgo2SfzUkE=
118 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
119 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
120 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
121 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
122 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
123 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
124 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
125 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
126 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
127 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
128 | golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
129 | golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
130 | golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
131 | golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
132 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
133 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
134 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
135 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
136 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
137 | golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
138 | golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
139 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
140 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
141 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
142 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
143 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
144 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
145 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
146 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
147 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
148 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
149 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
150 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
151 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
152 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
153 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
154 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
155 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
156 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
157 | golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
158 | golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
159 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
160 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
161 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
162 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
163 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
164 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
165 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
166 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
167 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
168 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
169 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
170 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
171 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
172 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
173 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
174 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
175 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
176 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
177 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
178 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
179 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
180 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
181 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
182 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
183 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
184 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
185 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
186 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
187 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
188 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
189 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
190 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
191 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
192 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
193 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
194 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
195 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
196 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
197 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
198 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
199 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
200 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
201 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
202 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
203 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
204 | golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
205 | golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
206 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
207 | google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
208 | google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
209 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
210 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
211 | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
212 | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
213 | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
214 | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
215 | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
216 | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
217 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
218 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
219 | modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q=
220 | modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
221 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
222 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
223 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
224 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
225 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
226 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
227 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
228 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
229 | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
230 | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
231 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
232 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
233 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
234 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
235 | rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
236 | rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
237 |
--------------------------------------------------------------------------------
/migrations.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "crypto/rand"
5 | "encoding/hex"
6 | "fmt"
7 | "strings"
8 |
9 | "github.com/jmoiron/sqlx"
10 | )
11 |
12 | type Migration struct {
13 | ID int
14 | Name string
15 | UpSQL string
16 | DownSQL string
17 | }
18 |
19 | var migrations = []Migration{
20 | {
21 | ID: 1,
22 | Name: "initial_schema",
23 | UpSQL: initialSchemaSQL,
24 | },
25 | {
26 | ID: 2,
27 | Name: "add_proxy_url",
28 | UpSQL: `
29 | -- PostgreSQL version
30 | DO $$
31 | BEGIN
32 | IF NOT EXISTS (
33 | SELECT 1 FROM information_schema.columns
34 | WHERE table_name = 'users' AND column_name = 'proxy_url'
35 | ) THEN
36 | ALTER TABLE users ADD COLUMN proxy_url TEXT DEFAULT '';
37 | END IF;
38 | END $$;
39 |
40 | -- SQLite version (handled in code)
41 | `,
42 | },
43 | {
44 | ID: 3,
45 | Name: "change_id_to_string",
46 | UpSQL: changeIDToStringSQL,
47 | },
48 | {
49 | ID: 4,
50 | Name: "add_s3_support",
51 | UpSQL: addS3SupportSQL,
52 | },
53 | {
54 | ID: 5,
55 | Name: "add_message_history",
56 | UpSQL: addMessageHistorySQL,
57 | },
58 | {
59 | ID: 6,
60 | Name: "add_quoted_message_id",
61 | UpSQL: addQuotedMessageIDSQL,
62 | },
63 | {
64 | ID: 7,
65 | Name: "add_hmac_key",
66 | UpSQL: addHmacKeySQL,
67 | },
68 | {
69 | ID: 8,
70 | Name: "add_data_json",
71 | UpSQL: addDataJsonSQL,
72 | },
73 | }
74 |
75 | const changeIDToStringSQL = `
76 | -- Migration to change ID from integer to random string
77 | DO $$
78 | BEGIN
79 | -- Only execute if the column is currently integer type
80 | IF EXISTS (
81 | SELECT 1 FROM information_schema.columns
82 | WHERE table_name = 'users' AND column_name = 'id' AND data_type = 'integer'
83 | ) THEN
84 | -- For PostgreSQL
85 | ALTER TABLE users ADD COLUMN new_id TEXT;
86 | UPDATE users SET new_id = md5(random()::text || id::text || clock_timestamp()::text);
87 | ALTER TABLE users DROP CONSTRAINT users_pkey;
88 | ALTER TABLE users DROP COLUMN id CASCADE;
89 | ALTER TABLE users RENAME COLUMN new_id TO id;
90 | ALTER TABLE users ALTER COLUMN id SET NOT NULL;
91 | ALTER TABLE users ADD PRIMARY KEY (id);
92 | END IF;
93 | END $$;
94 | `
95 |
96 | const initialSchemaSQL = `
97 | -- PostgreSQL version
98 | DO $$
99 | BEGIN
100 | IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'users') THEN
101 | CREATE TABLE users (
102 | id TEXT PRIMARY KEY,
103 | name TEXT NOT NULL,
104 | token TEXT NOT NULL,
105 | webhook TEXT NOT NULL DEFAULT '',
106 | jid TEXT NOT NULL DEFAULT '',
107 | qrcode TEXT NOT NULL DEFAULT '',
108 | connected INTEGER,
109 | expiration INTEGER,
110 | events TEXT NOT NULL DEFAULT '',
111 | proxy_url TEXT DEFAULT ''
112 | );
113 | END IF;
114 | END $$;
115 |
116 | -- SQLite version (handled in code)
117 | `
118 |
119 | const addS3SupportSQL = `
120 | -- PostgreSQL version
121 | DO $$
122 | BEGIN
123 | -- Add S3 configuration columns if they don't exist
124 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_enabled') THEN
125 | ALTER TABLE users ADD COLUMN s3_enabled BOOLEAN DEFAULT FALSE;
126 | END IF;
127 |
128 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_endpoint') THEN
129 | ALTER TABLE users ADD COLUMN s3_endpoint TEXT DEFAULT '';
130 | END IF;
131 |
132 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_region') THEN
133 | ALTER TABLE users ADD COLUMN s3_region TEXT DEFAULT '';
134 | END IF;
135 |
136 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_bucket') THEN
137 | ALTER TABLE users ADD COLUMN s3_bucket TEXT DEFAULT '';
138 | END IF;
139 |
140 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_access_key') THEN
141 | ALTER TABLE users ADD COLUMN s3_access_key TEXT DEFAULT '';
142 | END IF;
143 |
144 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_secret_key') THEN
145 | ALTER TABLE users ADD COLUMN s3_secret_key TEXT DEFAULT '';
146 | END IF;
147 |
148 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_path_style') THEN
149 | ALTER TABLE users ADD COLUMN s3_path_style BOOLEAN DEFAULT TRUE;
150 | END IF;
151 |
152 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_public_url') THEN
153 | ALTER TABLE users ADD COLUMN s3_public_url TEXT DEFAULT '';
154 | END IF;
155 |
156 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'media_delivery') THEN
157 | ALTER TABLE users ADD COLUMN media_delivery TEXT DEFAULT 'base64';
158 | END IF;
159 |
160 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 's3_retention_days') THEN
161 | ALTER TABLE users ADD COLUMN s3_retention_days INTEGER DEFAULT 30;
162 | END IF;
163 | END $$;
164 | `
165 |
166 | const addMessageHistorySQL = `
167 | -- PostgreSQL version
168 | DO $$
169 | BEGIN
170 | IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'message_history') THEN
171 | CREATE TABLE message_history (
172 | id SERIAL PRIMARY KEY,
173 | user_id TEXT NOT NULL,
174 | chat_jid TEXT NOT NULL,
175 | sender_jid TEXT NOT NULL,
176 | message_id TEXT NOT NULL,
177 | timestamp TIMESTAMP NOT NULL,
178 | message_type TEXT NOT NULL,
179 | text_content TEXT,
180 | media_link TEXT,
181 | UNIQUE(user_id, message_id)
182 | );
183 | CREATE INDEX idx_message_history_user_chat_timestamp ON message_history (user_id, chat_jid, timestamp DESC);
184 | END IF;
185 |
186 | -- Add history column to users table if it doesn't exist
187 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'history') THEN
188 | ALTER TABLE users ADD COLUMN history INTEGER DEFAULT 0;
189 | END IF;
190 | END $$;
191 | `
192 |
193 | const addQuotedMessageIDSQL = `
194 | -- PostgreSQL version
195 | DO $$
196 | BEGIN
197 | -- Add quoted_message_id column to message_history table if it doesn't exist
198 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'message_history' AND column_name = 'quoted_message_id') THEN
199 | ALTER TABLE message_history ADD COLUMN quoted_message_id TEXT;
200 | END IF;
201 | END $$;
202 | `
203 |
204 | const addDataJsonSQL = `
205 | -- PostgreSQL version
206 | DO $$
207 | BEGIN
208 | -- Add dataJson column to message_history table if it doesn't exist
209 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'message_history' AND column_name = 'datajson') THEN
210 | ALTER TABLE message_history ADD COLUMN datajson TEXT;
211 | END IF;
212 | END $$;
213 |
214 | -- SQLite version (handled in code)
215 | `
216 |
217 | // GenerateRandomID creates a random string ID
218 | func GenerateRandomID() (string, error) {
219 | bytes := make([]byte, 16) // 128 bits
220 | if _, err := rand.Read(bytes); err != nil {
221 | return "", fmt.Errorf("failed to generate random ID: %w", err)
222 | }
223 | return hex.EncodeToString(bytes), nil
224 | }
225 |
226 | // Initialize the database with migrations
227 | func initializeSchema(db *sqlx.DB) error {
228 | // Create migrations table if it doesn't exist
229 | if err := createMigrationsTable(db); err != nil {
230 | return fmt.Errorf("failed to create migrations table: %w", err)
231 | }
232 |
233 | // Get already applied migrations
234 | applied, err := getAppliedMigrations(db)
235 | if err != nil {
236 | return fmt.Errorf("failed to get applied migrations: %w", err)
237 | }
238 |
239 | // Apply missing migrations
240 | for _, migration := range migrations {
241 | if _, ok := applied[migration.ID]; !ok {
242 | if err := applyMigration(db, migration); err != nil {
243 | return fmt.Errorf("failed to apply migration %d: %w", migration.ID, err)
244 | }
245 | }
246 | }
247 |
248 | return nil
249 | }
250 |
251 | func createMigrationsTable(db *sqlx.DB) error {
252 | var tableExists bool
253 | var err error
254 |
255 | switch db.DriverName() {
256 | case "postgres":
257 | err = db.Get(&tableExists, `
258 | SELECT EXISTS (
259 | SELECT 1 FROM information_schema.tables
260 | WHERE table_name = 'migrations'
261 | )`)
262 | case "sqlite":
263 | err = db.Get(&tableExists, `
264 | SELECT EXISTS (
265 | SELECT 1 FROM sqlite_master
266 | WHERE type='table' AND name='migrations'
267 | )`)
268 | default:
269 | return fmt.Errorf("unsupported database driver: %s", db.DriverName())
270 | }
271 |
272 | if err != nil {
273 | return fmt.Errorf("failed to check migrations table existence: %w", err)
274 | }
275 |
276 | if tableExists {
277 | return nil
278 | }
279 |
280 | _, err = db.Exec(`
281 | CREATE TABLE migrations (
282 | id INTEGER PRIMARY KEY,
283 | name TEXT NOT NULL,
284 | applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
285 | )`)
286 | if err != nil {
287 | return fmt.Errorf("failed to create migrations table: %w", err)
288 | }
289 |
290 | return nil
291 | }
292 |
293 | func getAppliedMigrations(db *sqlx.DB) (map[int]struct{}, error) {
294 | applied := make(map[int]struct{})
295 | var rows []struct {
296 | ID int `db:"id"`
297 | Name string `db:"name"`
298 | }
299 |
300 | err := db.Select(&rows, "SELECT id, name FROM migrations ORDER BY id ASC")
301 | if err != nil {
302 | return nil, fmt.Errorf("failed to query applied migrations: %w", err)
303 | }
304 |
305 | for _, row := range rows {
306 | applied[row.ID] = struct{}{}
307 | }
308 |
309 | return applied, nil
310 | }
311 |
312 | func applyMigration(db *sqlx.DB, migration Migration) error {
313 | tx, err := db.Beginx()
314 | if err != nil {
315 | return fmt.Errorf("failed to begin transaction: %w", err)
316 | }
317 | defer func() {
318 | if err != nil {
319 | tx.Rollback()
320 | }
321 | }()
322 |
323 | if migration.ID == 1 {
324 | // Handle initial schema creation differently per database
325 | if db.DriverName() == "sqlite" {
326 | err = createTableIfNotExistsSQLite(tx, "users", `
327 | CREATE TABLE users (
328 | id TEXT PRIMARY KEY,
329 | name TEXT NOT NULL,
330 | token TEXT NOT NULL,
331 | webhook TEXT NOT NULL DEFAULT '',
332 | jid TEXT NOT NULL DEFAULT '',
333 | qrcode TEXT NOT NULL DEFAULT '',
334 | connected INTEGER,
335 | expiration INTEGER,
336 | events TEXT NOT NULL DEFAULT '',
337 | proxy_url TEXT DEFAULT ''
338 | )`)
339 | } else {
340 | _, err = tx.Exec(migration.UpSQL)
341 | }
342 | } else if migration.ID == 2 {
343 | if db.DriverName() == "sqlite" {
344 | err = addColumnIfNotExistsSQLite(tx, "users", "proxy_url", "TEXT DEFAULT ''")
345 | } else {
346 | _, err = tx.Exec(migration.UpSQL)
347 | }
348 | } else if migration.ID == 3 {
349 | if db.DriverName() == "sqlite" {
350 | err = migrateSQLiteIDToString(tx)
351 | } else {
352 | _, err = tx.Exec(migration.UpSQL)
353 | }
354 | } else if migration.ID == 4 {
355 | if db.DriverName() == "sqlite" {
356 | // Handle S3 columns for SQLite
357 | err = addColumnIfNotExistsSQLite(tx, "users", "s3_enabled", "BOOLEAN DEFAULT 0")
358 | if err == nil {
359 | err = addColumnIfNotExistsSQLite(tx, "users", "s3_endpoint", "TEXT DEFAULT ''")
360 | }
361 | if err == nil {
362 | err = addColumnIfNotExistsSQLite(tx, "users", "s3_region", "TEXT DEFAULT ''")
363 | }
364 | if err == nil {
365 | err = addColumnIfNotExistsSQLite(tx, "users", "s3_bucket", "TEXT DEFAULT ''")
366 | }
367 | if err == nil {
368 | err = addColumnIfNotExistsSQLite(tx, "users", "s3_access_key", "TEXT DEFAULT ''")
369 | }
370 | if err == nil {
371 | err = addColumnIfNotExistsSQLite(tx, "users", "s3_secret_key", "TEXT DEFAULT ''")
372 | }
373 | if err == nil {
374 | err = addColumnIfNotExistsSQLite(tx, "users", "s3_path_style", "BOOLEAN DEFAULT 1")
375 | }
376 | if err == nil {
377 | err = addColumnIfNotExistsSQLite(tx, "users", "s3_public_url", "TEXT DEFAULT ''")
378 | }
379 | if err == nil {
380 | err = addColumnIfNotExistsSQLite(tx, "users", "media_delivery", "TEXT DEFAULT 'base64'")
381 | }
382 | if err == nil {
383 | err = addColumnIfNotExistsSQLite(tx, "users", "s3_retention_days", "INTEGER DEFAULT 30")
384 | }
385 | } else {
386 | _, err = tx.Exec(migration.UpSQL)
387 | }
388 | } else if migration.ID == 5 {
389 | if db.DriverName() == "sqlite" {
390 | // Handle message_history table creation for SQLite
391 | err = createTableIfNotExistsSQLite(tx, "message_history", `
392 | CREATE TABLE message_history (
393 | id INTEGER PRIMARY KEY AUTOINCREMENT,
394 | user_id TEXT NOT NULL,
395 | chat_jid TEXT NOT NULL,
396 | sender_jid TEXT NOT NULL,
397 | message_id TEXT NOT NULL,
398 | timestamp DATETIME NOT NULL,
399 | message_type TEXT NOT NULL,
400 | text_content TEXT,
401 | media_link TEXT,
402 | UNIQUE(user_id, message_id)
403 | )`)
404 | if err == nil {
405 | // Create index for SQLite
406 | _, err = tx.Exec(`
407 | CREATE INDEX IF NOT EXISTS idx_message_history_user_chat_timestamp
408 | ON message_history (user_id, chat_jid, timestamp DESC)`)
409 | }
410 | if err == nil {
411 | // Add history column to users table
412 | err = addColumnIfNotExistsSQLite(tx, "users", "history", "INTEGER DEFAULT 0")
413 | }
414 | } else {
415 | _, err = tx.Exec(migration.UpSQL)
416 | }
417 | } else if migration.ID == 6 {
418 | if db.DriverName() == "sqlite" {
419 | // Add quoted_message_id column to message_history table for SQLite
420 | err = addColumnIfNotExistsSQLite(tx, "message_history", "quoted_message_id", "TEXT")
421 | } else {
422 | _, err = tx.Exec(migration.UpSQL)
423 | }
424 | } else if migration.ID == 7 {
425 | if db.DriverName() == "sqlite" {
426 | // Add hmac_key column as BLOB for encrypted data in SQLite
427 | err = addColumnIfNotExistsSQLite(tx, "users", "hmac_key", "BLOB")
428 | } else {
429 | _, err = tx.Exec(migration.UpSQL)
430 | }
431 | } else if migration.ID == 8 {
432 | if db.DriverName() == "sqlite" {
433 | // Add dataJson column to message_history table for SQLite
434 | err = addColumnIfNotExistsSQLite(tx, "message_history", "datajson", "TEXT")
435 | } else {
436 | _, err = tx.Exec(migration.UpSQL)
437 | }
438 | } else {
439 | _, err = tx.Exec(migration.UpSQL)
440 | }
441 |
442 | if err != nil {
443 | return fmt.Errorf("failed to execute migration SQL: %w", err)
444 | }
445 |
446 | // Record the migration
447 | if _, err = tx.Exec(`
448 | INSERT INTO migrations (id, name)
449 | VALUES ($1, $2)`, migration.ID, migration.Name); err != nil {
450 | return fmt.Errorf("failed to record migration: %w", err)
451 | }
452 |
453 | return tx.Commit()
454 | }
455 |
456 | func createTableIfNotExistsSQLite(tx *sqlx.Tx, tableName, createSQL string) error {
457 | var exists int
458 | err := tx.Get(&exists, `
459 | SELECT COUNT(*) FROM sqlite_master
460 | WHERE type='table' AND name=?`, tableName)
461 | if err != nil {
462 | return err
463 | }
464 |
465 | if exists == 0 {
466 | _, err = tx.Exec(createSQL)
467 | return err
468 | }
469 | return nil
470 | }
471 | func sqliteChangeIDType(tx *sqlx.Tx) error {
472 | // SQLite requires a more complex approach:
473 | // 1. Create new table with string ID
474 | // 2. Copy data with new UUIDs
475 | // 3. Drop old table
476 | // 4. Rename new table
477 |
478 | // Step 1: Get the current schema
479 | var tableInfo string
480 | err := tx.Get(&tableInfo, `
481 | SELECT sql FROM sqlite_master
482 | WHERE type='table' AND name='users'`)
483 | if err != nil {
484 | return fmt.Errorf("failed to get table info: %w", err)
485 | }
486 |
487 | // Step 2: Create new table with string ID
488 | newTableSQL := strings.Replace(tableInfo,
489 | "CREATE TABLE users (",
490 | "CREATE TABLE users_new (id TEXT PRIMARY KEY, ", 1)
491 | newTableSQL = strings.Replace(newTableSQL,
492 | "id INTEGER PRIMARY KEY AUTOINCREMENT,", "", 1)
493 |
494 | if _, err = tx.Exec(newTableSQL); err != nil {
495 | return fmt.Errorf("failed to create new table: %w", err)
496 | }
497 |
498 | // Step 3: Copy data with new UUIDs
499 | columns, err := getTableColumns(tx, "users")
500 | if err != nil {
501 | return fmt.Errorf("failed to get table columns: %w", err)
502 | }
503 |
504 | // Remove 'id' from columns list
505 | var filteredColumns []string
506 | for _, col := range columns {
507 | if col != "id" {
508 | filteredColumns = append(filteredColumns, col)
509 | }
510 | }
511 |
512 | columnList := strings.Join(filteredColumns, ", ")
513 | if _, err = tx.Exec(fmt.Sprintf(`
514 | INSERT INTO users_new (id, %s)
515 | SELECT gen_random_uuid(), %s FROM users`,
516 | columnList, columnList)); err != nil {
517 | return fmt.Errorf("failed to copy data: %w", err)
518 | }
519 |
520 | // Step 4: Drop old table
521 | if _, err = tx.Exec("DROP TABLE users"); err != nil {
522 | return fmt.Errorf("failed to drop old table: %w", err)
523 | }
524 |
525 | // Step 5: Rename new table
526 | if _, err = tx.Exec("ALTER TABLE users_new RENAME TO users"); err != nil {
527 | return fmt.Errorf("failed to rename table: %w", err)
528 | }
529 |
530 | return nil
531 | }
532 |
533 | func getTableColumns(tx *sqlx.Tx, tableName string) ([]string, error) {
534 | var columns []string
535 | rows, err := tx.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
536 | if err != nil {
537 | return nil, fmt.Errorf("failed to get table info: %w", err)
538 | }
539 | defer rows.Close()
540 |
541 | for rows.Next() {
542 | var cid int
543 | var name, typ string
544 | var notnull int
545 | var dfltValue interface{}
546 | var pk int
547 |
548 | if err := rows.Scan(&cid, &name, &typ, ¬null, &dfltValue, &pk); err != nil {
549 | return nil, fmt.Errorf("failed to scan column info: %w", err)
550 | }
551 | columns = append(columns, name)
552 | }
553 |
554 | return columns, nil
555 | }
556 |
557 | func migrateSQLiteIDToString(tx *sqlx.Tx) error {
558 | // 1. Check if we need to do the migration
559 | var currentType string
560 | err := tx.QueryRow(`
561 | SELECT type FROM pragma_table_info('users')
562 | WHERE name = 'id'`).Scan(¤tType)
563 | if err != nil {
564 | return fmt.Errorf("failed to check column type: %w", err)
565 | }
566 |
567 | if currentType != "INTEGER" {
568 | // No migration needed
569 | return nil
570 | }
571 |
572 | // 2. Create new table with string ID
573 | _, err = tx.Exec(`
574 | CREATE TABLE users_new (
575 | id TEXT PRIMARY KEY,
576 | name TEXT NOT NULL,
577 | token TEXT NOT NULL,
578 | webhook TEXT NOT NULL DEFAULT '',
579 | jid TEXT NOT NULL DEFAULT '',
580 | qrcode TEXT NOT NULL DEFAULT '',
581 | connected INTEGER,
582 | expiration INTEGER,
583 | events TEXT NOT NULL DEFAULT '',
584 | proxy_url TEXT DEFAULT ''
585 | )`)
586 | if err != nil {
587 | return fmt.Errorf("failed to create new table: %w", err)
588 | }
589 |
590 | // 3. Copy data with new UUIDs
591 | _, err = tx.Exec(`
592 | INSERT INTO users_new
593 | SELECT
594 | hex(randomblob(16)),
595 | name, token, webhook, jid, qrcode,
596 | connected, expiration, events, proxy_url
597 | FROM users`)
598 | if err != nil {
599 | return fmt.Errorf("failed to copy data: %w", err)
600 | }
601 |
602 | // 4. Drop old table
603 | _, err = tx.Exec(`DROP TABLE users`)
604 | if err != nil {
605 | return fmt.Errorf("failed to drop old table: %w", err)
606 | }
607 |
608 | // 5. Rename new table
609 | _, err = tx.Exec(`ALTER TABLE users_new RENAME TO users`)
610 | if err != nil {
611 | return fmt.Errorf("failed to rename table: %w", err)
612 | }
613 |
614 | return nil
615 | }
616 |
617 | func addColumnIfNotExistsSQLite(tx *sqlx.Tx, tableName, columnName, columnDef string) error {
618 | var exists int
619 | err := tx.Get(&exists, `
620 | SELECT COUNT(*) FROM pragma_table_info(?)
621 | WHERE name = ?`, tableName, columnName)
622 | if err != nil {
623 | return fmt.Errorf("failed to check column existence: %w", err)
624 | }
625 |
626 | if exists == 0 {
627 | _, err = tx.Exec(fmt.Sprintf(
628 | "ALTER TABLE %s ADD COLUMN %s %s",
629 | tableName, columnName, columnDef))
630 | if err != nil {
631 | return fmt.Errorf("failed to add column: %w", err)
632 | }
633 | }
634 | return nil
635 | }
636 |
637 | const addHmacKeySQL = `
638 | -- PostgreSQL version - Add encrypted HMAC key column
639 | DO $$
640 | BEGIN
641 | -- Add hmac_key column as BYTEA for encrypted data
642 | IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'hmac_key') THEN
643 | ALTER TABLE users ADD COLUMN hmac_key BYTEA;
644 | END IF;
645 | END $$;
646 |
647 | -- SQLite version (handled in code)
648 | `
649 |
--------------------------------------------------------------------------------
/static/github-markdown-css/github-css.css:
--------------------------------------------------------------------------------
1 | @charset "utf-8"; .gist{font-size:16px;color:#333;text-align:left;/*!* GitHub Light v0.4.1 * Copyright(c) 2012 - 2017 GitHub,Inc. * Licensed under MIT(https://github.com/primer/github-syntax-theme-generator/blob/master/LICENSE) */ direction:ltr}.gist .markdown-body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;font-size:16px;line-height:1.5;word-wrap:break-word}.gist .markdown-body kbd{display:inline-block;padding:3px 5px;font:11px SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;line-height:10px;color:#444d56;vertical-align:middle;background-color:#fafbfc;border:1px solid #d1d5da;border-radius:3px;box-shadow:inset 0 -1px 0 #d1d5da}.gist .markdown-body:before{display:table;content:""}.gist .markdown-body:after{display:table;clear:both;content:""}.gist .markdown-body>:first-child{margin-top:0 !important}.gist .markdown-body>:last-child{margin-bottom:0 !important}.gist .markdown-body a:not([href]){color:inherit;text-decoration:none}.gist .markdown-body .absent{color:#cb2431}.gist .markdown-body .anchor{float:left;padding-right:4px;margin-left:-20px;line-height:1}.gist .markdown-body .anchor:focus{outline:none}.gist .markdown-body blockquote,.gist .markdown-body details,.gist .markdown-body dl,.gist .markdown-body ol,.gist .markdown-body p,.gist .markdown-body pre,.gist .markdown-body table,.gist .markdown-body ul{margin-top:0;margin-bottom:16px}.gist .markdown-body hr{height:.25em;padding:0;margin:24px 0;background-color:#e1e4e8;border:0}.gist .markdown-body blockquote{padding:0 1em;color:#6a737d;border-left:.25em solid #dfe2e5}.gist .markdown-body blockquote>:first-child{margin-top:0}.gist .markdown-body blockquote>:last-child{margin-bottom:0}.gist .markdown-body h1,.gist .markdown-body h2,.gist .markdown-body h3,.gist .markdown-body h4,.gist .markdown-body h5,.gist .markdown-body h6{margin-top:24px;margin-bottom:16px;font-weight:600;line-height:1.25}.gist .markdown-body h1 .octicon-link,.gist .markdown-body h2 .octicon-link,.gist .markdown-body h3 .octicon-link,.gist .markdown-body h4 .octicon-link,.gist .markdown-body h5 .octicon-link,.gist .markdown-body h6 .octicon-link{color:#1b1f23;vertical-align:middle;visibility:hidden}.gist .markdown-body h1:hover .anchor,.gist .markdown-body h2:hover .anchor,.gist .markdown-body h3:hover .anchor,.gist .markdown-body h4:hover .anchor,.gist .markdown-body h5:hover .anchor,.gist .markdown-body h6:hover .anchor{text-decoration:none}.gist .markdown-body h1:hover .anchor .octicon-link,.gist .markdown-body h2:hover .anchor .octicon-link,.gist .markdown-body h3:hover .anchor .octicon-link,.gist .markdown-body h4:hover .anchor .octicon-link,.gist .markdown-body h5:hover .anchor .octicon-link,.gist .markdown-body h6:hover .anchor .octicon-link{visibility:visible}.gist .markdown-body h1 code,.gist .markdown-body h1 tt,.gist .markdown-body h2 code,.gist .markdown-body h2 tt,.gist .markdown-body h3 code,.gist .markdown-body h3 tt,.gist .markdown-body h4 code,.gist .markdown-body h4 tt,.gist .markdown-body h5 code,.gist .markdown-body h5 tt,.gist .markdown-body h6 code,.gist .markdown-body h6 tt{font-size:inherit}.gist .markdown-body h1{font-size:2em}.gist .markdown-body h1,.gist .markdown-body h2{padding-bottom:.3em;border-bottom:1px solid #eaecef}.gist .markdown-body h2{font-size:1.5em}.gist .markdown-body h3{font-size:1.25em}.gist .markdown-body h4{font-size:1em}.gist .markdown-body h5{font-size:.875em}.gist .markdown-body h6{font-size:.85em;color:#6a737d}.gist .markdown-body ol,.gist .markdown-body ul{padding-left:2em}.gist .markdown-body ol.no-list,.gist .markdown-body ul.no-list{padding:0;list-style-type:none}.gist .markdown-body ol ol,.gist .markdown-body ol ul,.gist .markdown-body ul ol,.gist .markdown-body ul ul{margin-top:0;margin-bottom:0}.gist .markdown-body li{word-wrap:break-all}.gist .markdown-body li>p{margin-top:16px}.gist .markdown-body li+li{margin-top:.25em}.gist .markdown-body dl{padding:0}.gist .markdown-body dl dt{padding:0;margin-top:16px;font-size:1em;font-style:italic;font-weight:600}.gist .markdown-body dl dd{padding:0 16px;margin-bottom:16px}.gist .markdown-body table{display:block;width:100%;overflow:auto}.gist .markdown-body table th{font-weight:600}.gist .markdown-body table td,.gist .markdown-body table th{padding:6px 13px;border:1px solid #dfe2e5}.gist .markdown-body table tr{background-color:#fff;border-top:1px solid #c6cbd1}.gist .markdown-body table tr:nth-child(2n){background-color:#f6f8fa}.gist .markdown-body table img{background-color:initial}.gist .markdown-body img{max-width:100%;box-sizing:initial;background-color:#fff}.gist .markdown-body img[align=right]{padding-left:20px}.gist .markdown-body img[align=left]{padding-right:20px}.gist .markdown-body .emoji{max-width:none;vertical-align:text-top;background-color:initial}.gist .markdown-body span.frame{display:block;overflow:hidden}.gist .markdown-body span.frame>span{display:block;float:left;width:auto;padding:7px;margin:13px 0 0;overflow:hidden;border:1px solid #dfe2e5}.gist .markdown-body span.frame span img{display:block;float:left}.gist .markdown-body span.frame span span{display:block;padding:5px 0 0;clear:both;color:#24292e}.gist .markdown-body span.align-center{display:block;overflow:hidden;clear:both}.gist .markdown-body span.align-center>span{display:block;margin:13px auto 0;overflow:hidden;text-align:center}.gist .markdown-body span.align-center span img{margin:0 auto;text-align:center}.gist .markdown-body span.align-right{display:block;overflow:hidden;clear:both}.gist .markdown-body span.align-right>span{display:block;margin:13px 0 0;overflow:hidden;text-align:right}.gist .markdown-body span.align-right span img{margin:0;text-align:right}.gist .markdown-body span.float-left{display:block;float:left;margin-right:13px;overflow:hidden}.gist .markdown-body span.float-left span{margin:13px 0 0}.gist .markdown-body span.float-right{display:block;float:right;margin-left:13px;overflow:hidden}.gist .markdown-body span.float-right>span{display:block;margin:13px auto 0;overflow:hidden;text-align:right}.gist .markdown-body code,.gist .markdown-body tt{padding:.2em .4em;margin:0;font-size:85%;background-color:#f6f8fa;border-radius:3px}.gist .markdown-body code br,.gist .markdown-body tt br{display:none}.gist .markdown-body del code{text-decoration:inherit}.gist .markdown-body pre{word-wrap:normal}.gist .markdown-body pre>code{padding:0;margin:0;font-size:100%;word-break:normal;white-space:pre;background:transparent;border:0}.gist .markdown-body .highlight{margin-bottom:16px}.gist .markdown-body .highlight pre{margin-bottom:0;word-break:normal}.gist .markdown-body .highlight pre,.gist .markdown-body pre{padding:16px;overflow:auto;font-size:85%;line-height:1.45;background-color:#f6f8fa;border-radius:3px}.gist .markdown-body pre code,.gist .markdown-body pre tt{display:inline;max-width:auto;padding:0;margin:0;overflow:visible;line-height:inherit;word-wrap:normal;background-color:#f6f8fa;border:0}.gist .markdown-body .csv-data td,.gist .markdown-body .csv-data th{padding:5px;overflow:hidden;font-size:12px;line-height:1;text-align:left;white-space:nowrap}.gist .markdown-body .csv-data .blob-num{padding:10px 8px 9px;text-align:right;background:#fff;border:0}.gist .markdown-body .csv-data tr{border-top:0}.gist .markdown-body .csv-data th{font-weight:600;background:#f6f8fa;border-top:0}.gist .pl-c{color:#6a737d}.gist .pl-c1,.gist .pl-s .pl-v{color:#005cc5}.gist .pl-e,.gist .pl-en{color:#6f42c1}.gist .pl-s .pl-s1,.gist .pl-smi{color:#24292e}.gist .pl-ent{color:#22863a}.gist .pl-k{color:#d73a49}.gist .pl-pds,.gist .pl-s,.gist .pl-s .pl-pse .pl-s1,.gist .pl-sr,.gist .pl-sr .pl-cce,.gist .pl-sr .pl-sra,.gist .pl-sr .pl-sre{color:#032f62}.gist .pl-smw,.gist .pl-v{color:#e36209}.gist .pl-bu{color:#b31d28}.gist .pl-ii{color:#fafbfc;background-color:#b31d28}.gist .pl-c2{color:#fafbfc;background-color:#d73a49}.gist .pl-c2:before{content:"^M"}.gist .pl-sr .pl-cce{font-weight:700;color:#22863a}.gist .pl-ml{color:#735c0f}.gist .pl-mh,.gist .pl-mh .pl-en,.gist .pl-ms{font-weight:700;color:#005cc5}.gist .pl-mi{font-style:italic;color:#24292e}.gist .pl-mb{font-weight:700;color:#24292e}.gist .pl-md{color:#b31d28;background-color:#ffeef0}.gist .pl-mi1{color:#22863a;background-color:#f0fff4}.gist .pl-mc{color:#e36209;background-color:#ffebda}.gist .pl-mi2{color:#f6f8fa;background-color:#005cc5}.gist .pl-mdr{font-weight:700;color:#6f42c1}.gist .pl-ba{color:#586069}.gist .pl-sg{color:#959da5}.gist .pl-corl{text-decoration:underline;color:#032f62}.gist .breadcrumb{font-size:16px;color:#586069}.gist .breadcrumb .separator{white-space:pre-wrap}.gist .breadcrumb .separator:after,.gist .breadcrumb .separator:before{content:" "}.gist .breadcrumb strong.final-path{color:#24292e}.gist strong{font-weight:bolder}.gist .editor-abort{display:inline;font-size:14px}.gist .blob-interaction-bar{position:relative;background-color:#f2f2f2;border-bottom:1px solid #e5e5e5}.gist .blob-interaction-bar:before{display:table;content:""}.gist .blob-interaction-bar:after{display:table;clear:both;content:""}.gist .blob-interaction-bar .octicon-search{position:absolute;top:10px;left:10px;font-size:12px;color:#586069}.gist .blob-filter{width:100%;padding:4px 20px 5px 30px;font-size:12px;border:0;border-radius:0;outline:none}.gist .blob-filter:focus{outline:none}.gist .html-blob{margin-bottom:15px}.gist .TagsearchPopover{width:inherit;max-width:600px}.gist .TagsearchPopover-content{max-height:300px}.gist .TagsearchPopover-list .TagsearchPopover-list-item:hover{background-color:#f6f8fa}.gist .TagsearchPopover-list .TagsearchPopover-list-item .TagsearchPopover-item:hover{text-decoration:none}.gist .TagsearchPopover-list .blob-code-inner{white-space:pre-wrap}.gist .linejump .linejump-input{width:340px;background-color:#fafbfc}.gist .linejump .btn,.gist .linejump .linejump-input{padding:10px 15px;font-size:16px}.gist .CopyBlock{line-height:20px;cursor:pointer}.gist .CopyBlock .octicon-clippy{display:none}.gist .CopyBlock:active,.gist .CopyBlock:focus,.gist .CopyBlock:hover{background-color:#fff;outline:none}.gist .CopyBlock:active .octicon-clippy,.gist .CopyBlock:focus .octicon-clippy,.gist .CopyBlock:hover .octicon-clippy{display:inline-block}.gist .blob-wrapper{overflow-x:auto;overflow-y:hidden}.gist .page-blob.height-full .blob-wrapper{overflow-y:auto}.gist .page-edit-blob.height-full .CodeMirror{height:300px}.gist .page-edit-blob.height-full .CodeMirror,.gist .page-edit-blob.height-full .CodeMirror-scroll{display:flex;flex-direction:column;flex:1 1 auto}.gist .blob-wrapper-embedded{max-height:240px;overflow-y:auto}.gist .diff-table{width:100%;border-collapse:initial}.gist .diff-table .line-comments{padding:10px;vertical-align:top;border-top:1px solid #e1e4e8}.gist .diff-table .line-comments:first-child+.empty-cell{border-left-width:1px}.gist .diff-table tr:not(:last-child) .line-comments{border-top:1px solid #e1e4e8;border-bottom:1px solid #e1e4e8}.gist .blob-num{width:1%;min-width:50px;padding-right:10px;padding-left:10px;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:12px;line-height:20px;color:rgba(27,31,35,.3);text-align:right;white-space:nowrap;vertical-align:top;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.gist .blob-num:hover{color:rgba(27,31,35,.6)}.gist .blob-num:before{content:attr(data-line-number)}.gist .blob-num.non-expandable{cursor:default}.gist .blob-num.non-expandable:hover{color:rgba(27,31,35,.3)}.gist .blob-code{position:relative;padding-right:10px;padding-left:10px;line-height:20px;vertical-align:top}.gist .blob-code-inner{overflow:visible;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:12px;color:#24292e;word-wrap:normal;white-space:pre}.gist .blob-code-inner .x-first{border-top-left-radius:.2em;border-bottom-left-radius:.2em}.gist .blob-code-inner .x-last{border-top-right-radius:.2em;border-bottom-right-radius:.2em}.gist .blob-code-inner.highlighted,.gist .blob-code-inner .highlighted{background-color:#fffbdd}.gist .blob-code-marker:before{padding-right:8px;content:attr(data-code-marker)}.gist .blob-code-marker-addition:before{content:"+ "}.gist .blob-code-marker-deletion:before{content:"- "}.gist .blob-code-marker-context:before{content:" "}.gist .soft-wrap .diff-table{table-layout:fixed}.gist .soft-wrap .blob-code{padding-left:18px;text-indent:-7px}.gist .soft-wrap .blob-code-inner{word-wrap:break-word;white-space:pre-wrap}.gist .soft-wrap .no-nl-marker{display:none}.gist .soft-wrap .add-line-comment{margin-left:-28px}.gist .blob-code-hunk,.gist .blob-num-expandable,.gist .blob-num-hunk{color:rgba(27,31,35,.7);vertical-align:middle}.gist .blob-num-expandable,.gist .blob-num-hunk{background-color:#dbedff}.gist .blob-code-hunk{padding-top:4px;padding-bottom:4px;background-color:#f1f8ff;border-width:1px 0}.gist .blob-expanded .blob-code,.gist .blob-expanded .blob-num{background-color:#fafbfc}.gist .blob-expanded+tr:not(.blob-expanded) .blob-code,.gist .blob-expanded+tr:not(.blob-expanded) .blob-num,.gist .blob-expanded .blob-num-hunk,.gist tr:not(.blob-expanded)+.blob-expanded .blob-code,.gist tr:not(.blob-expanded)+.blob-expanded .blob-num{border-top:1px solid #eaecef}.gist .blob-num-expandable{padding:0;font-size:12px;text-align:center}.gist .blob-num-expandable .diff-expander{display:block;width:auto;height:auto;padding:4px 11px 4px 10px;margin-right:-1px;color:#586069;cursor:pointer}.gist .blob-num-expandable .diff-expander .octicon{vertical-align:top}.gist .blob-num-expandable .directional-expander{display:block;width:auto;height:auto;margin-right:-1px;color:#586069;cursor:pointer}.gist .blob-num-expandable .single-expander{padding-top:4px;padding-bottom:4px}.gist .blob-num-expandable .diff-expander:hover,.gist .blob-num-expandable .directional-expander:hover{color:#fff;text-shadow:none;background-color:#0366d6;border-color:#0366d6}.gist .blob-code-addition{background-color:#e6ffed}.gist .blob-code-addition .x{color:#24292e;background-color:#acf2bd}.gist .blob-num-addition{background-color:#cdffd8;border-color:#bef5cb}.gist .blob-code-deletion{background-color:#ffeef0}.gist .blob-code-deletion .x{color:#24292e;background-color:#fdb8c0}.gist .blob-num-deletion{background-color:#ffdce0;border-color:#fdaeb7}.gist .is-selecting,.gist .is-selecting .blob-num{cursor:ns-resize !important}.gist .is-selecting .add-line-comment,.gist .is-selecting a{pointer-events:none;cursor:ns-resize !important}.gist .is-selecting .is-hovered .add-line-comment{opacity:0}.gist .is-selecting.file-diff-split,.gist .is-selecting.file-diff-split .blob-num{cursor:nwse-resize !important}.gist .is-selecting.file-diff-split .add-line-comment,.gist .is-selecting.file-diff-split .empty-cell,.gist .is-selecting.file-diff-split a{pointer-events:none;cursor:nwse-resize !important}.gist .selected-line{position:relative}.gist .selected-line:after{position:absolute;top:0;left:0;display:block;width:100%;height:100%;box-sizing:border-box;pointer-events:none;content:"";background:rgba(255,223,93,.2);mix-blend-mode:multiply}.gist .selected-line.selected-line-top:after{border-top:1px solid #ffd33d}.gist .selected-line.selected-line-bottom:after{border-bottom:1px solid #ffd33d}.gist .selected-line.selected-line-left:after,.gist .selected-line:first-child:after{border-left:1px solid #ffd33d}.gist .selected-line.selected-line-right:after,.gist .selected-line:last-child:after{border-right:1px solid #ffd33d}.gist .is-commenting .selected-line.blob-code:before{position:absolute;top:0;left:-1px;display:block;width:4px;height:100%;content:"";background:#0366d6}.gist .add-line-comment{position:relative;z-index:5;float:left;width:22px;height:22px;margin:-2px -10px -2px -20px;line-height:21px;color:#fff;text-align:center;text-indent:0;cursor:pointer;background-color:#0366d6;background-image:linear-gradient(#0372ef,#0366d6);border-radius:3px;box-shadow:0 1px 4px rgba(27,31,35,.15);opacity:0;transition:transform .1s ease-in-out;transform:scale(.8)}.gist .add-line-comment:hover{transform:scale(1)}.gist .add-line-comment:focus,.is-hovered .gist .add-line-comment{opacity:1}.gist .add-line-comment .octicon{vertical-align:text-top;pointer-events:none}.gist .add-line-comment.octicon-check{background:#333;opacity:1}.gist .inline-comment-form{border:1px solid #dfe2e5;border-radius:3px}.gist .inline-review-comment{margin-top:0 !important;margin-bottom:10px !important}.gist .inline-review-comment .gc:first-child+tr .blob-code,.gist .inline-review-comment .gc:first-child+tr .blob-num{padding-top:5px}.gist .inline-review-comment tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.gist .inline-review-comment tr:last-child .blob-code,.gist .inline-review-comment tr:last-child .blob-num{padding-bottom:8px}.gist .inline-review-comment tr:last-child .blob-code:first-child,.gist .inline-review-comment tr:last-child .blob-num:first-child{border-bottom-left-radius:3px}.gist .inline-review-comment tr:last-child .blob-code:last-child,.gist .inline-review-comment tr:last-child .blob-num:last-child{border-bottom-right-radius:3px}.gist .timeline-inline-comments{width:100%;table-layout:fixed}.gist .show-inline-notes .inline-comments,.gist .timeline-inline-comments .inline-comments{display:table-row}.gist .inline-comments,.gist .inline-comments.is-collapsed{display:none}.gist .inline-comments .line-comments.is-collapsed{visibility:hidden}.gist .inline-comments .line-comments+.blob-num{border-left-width:1px}.gist .inline-comments .timeline-comment{margin-bottom:10px}.gist .comment-holder,.gist .inline-comments .inline-comment-form,.gist .inline-comments .inline-comment-form-container{max-width:780px}.gist .empty-cell+.line-comments,.gist .line-comments+.line-comments{border-left:1px solid #eaecef}.gist .inline-comment-form-container .inline-comment-form,.gist .inline-comment-form-container.open .inline-comment-form-actions{display:none}.gist .inline-comment-form-container .inline-comment-form-actions,.gist .inline-comment-form-container.open .inline-comment-form{display:block}.gist body.full-width .container,.gist body.full-width .container-lg,.gist body.full-width .container-xl,.gist body.split-diff .container,.gist body.split-diff .container-lg,.gist body.split-diff .container-xl{width:100%;max-width:none;padding-right:20px;padding-left:20px}.gist body.full-width .repository-content,.gist body.split-diff .repository-content{width:100%}.gist body.full-width .new-pr-form,.gist body.split-diff .new-pr-form{max-width:980px}.gist .file-diff-split{table-layout:fixed}.gist .file-diff-split .blob-code+.blob-num{border-left:1px solid #f6f8fa}.gist .file-diff-split .blob-code-inner{word-wrap:break-word;white-space:pre-wrap}.gist .file-diff-split .empty-cell{cursor:default;background-color:#fafbfc;border-right-color:#eaecef}@media (max-width:1280px){.gist .file-diff-split .write-selected .comment-form-head{margin-bottom:48px !important}.gist .file-diff-split markdown-toolbar{position:absolute;right:8px;bottom:-40px}}.gist .submodule-diff-stats .octicon-diff-removed{color:#cb2431}.gist .submodule-diff-stats .octicon-diff-renamed{color:#677a85}.gist .submodule-diff-stats .octicon-diff-modified{color:#d0b44c}.gist .submodule-diff-stats .octicon-diff-added{color:#28a745}.gist .BlobToolbar{left:-17px}.gist .BlobToolbar-dropdown{margin-left:-2px}.gist .code-navigation-banner{background:linear-gradient(180deg,rgba(242,248,254,0),rgba(242,248,254,.47))}.gist .code-navigation-banner .code-navigation-banner-illo{background-image:url(code-navigation-banner-illo.svg);background-repeat:no-repeat;background-position:50%}.gist .pl-token.active,.gist .pl-token:hover{cursor:pointer;background:#ffea7f}.gist .task-list-item{list-style-type:none}.gist .task-list-item label{font-weight:400}.gist .task-list-item.enabled label{cursor:pointer}.gist .task-list-item+.task-list-item{margin-top:3px}.gist .task-list-item .handle{display:none}.gist .task-list-item-checkbox{margin:0 .2em .25em -1.6em;vertical-align:middle}.gist .reorderable-task-lists .markdown-body .contains-task-list{padding:0}.gist .reorderable-task-lists .markdown-body li:not(.task-list-item){margin-left:26px}.gist .reorderable-task-lists .markdown-body ol:not(.contains-task-list) li,.gist .reorderable-task-lists .markdown-body ul:not(.contains-task-list) li{margin-left:0}.gist .reorderable-task-lists .markdown-body li p{margin-top:0}.gist .reorderable-task-lists .markdown-body .task-list-item{padding-right:15px;padding-left:42px;margin-right:-15px;margin-left:-15px;border:1px solid transparent}.gist .reorderable-task-lists .markdown-body .task-list-item+.task-list-item{margin-top:0}.gist .reorderable-task-lists .markdown-body .task-list-item .contains-task-list{padding-top:4px}.gist .reorderable-task-lists .markdown-body .task-list-item .handle{display:block;float:left;width:20px;padding:2px 0 0 2px;margin-left:-43px;opacity:0}.gist .reorderable-task-lists .markdown-body .task-list-item .drag-handle{fill:#333}.gist .reorderable-task-lists .markdown-body .task-list-item.hovered>.handle{opacity:1}.gist .reorderable-task-lists .markdown-body .task-list-item.is-dragging{opacity:0}.gist .review-comment-contents .markdown-body .task-list-item{padding-left:42px;margin-right:-12px;margin-left:-12px;border-top-left-radius:3px;border-bottom-left-radius:3px}.gist .review-comment-contents .markdown-body .task-list-item.hovered{border-left-color:#ededed}.gist .highlight{padding:0;margin:0;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:12px;font-weight:400;line-height:1.4;color:#333;background:#fff;border:0}.gist .octospinner,.gist .render-viewer-error,.gist .render-viewer-fatal,.gist .render-viewer-invalid{display:none}.gist iframe.render-viewer{width:100%;height:480px;overflow:hidden;border:0}.gist code,.gist pre{font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace !important}.gist .gist-meta{padding:10px;overflow:hidden;font:12px -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;color:#586069;background-color:#f7f7f7;border-radius:0 0 3px 3px}.gist .gist-meta a{font-weight:600;color:#666;text-decoration:none;border:0}.gist .gist-data{overflow:auto;word-wrap:normal;background-color:#fff;border-bottom:1px solid #ddd;border-radius:3px 3px 0 0}.gist .gist-file{margin-bottom:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;border:1px solid;border-color:#ddd #ddd #ccc;border-radius:3px}.gist .gist-file article{padding:6px}.gist .gist-file .scroll .gist-data{position:absolute;top:0;right:0;bottom:30px;left:0;overflow:scroll}.gist .gist-file .scroll .gist-meta{position:absolute;right:0;bottom:0;left:0}.gist .blob-num{min-width:inherit}.gist .blob-code,.gist .blob-num{padding:1px 10px !important;background:transparent}.gist .blob-code{text-align:left;border:0}.gist .blob-wrapper table{border-collapse:collapse}.gist table,.gist table tr,.gist table tr td,.gist table tr th{border-collapse:collapse}.gist .blob-wrapper tr:first-child td{padding-top:4px}.gist .markdown-body .anchor{display:none}
--------------------------------------------------------------------------------