├── 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 |
16 |
17 | 21 | 22 | 27 | 28 | 38 |
39 |
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 | 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 |
203 |
W
204 |
205 |

WUZAPI Assistant

206 | Online 207 |
208 |
209 |
210 | 211 |
212 |
213 |
214 |
215 | 216 | 217 | 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 |
250 |
go build .
251 |
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 | 347 | 348 | 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} --------------------------------------------------------------------------------