diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..0eff794 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,71 @@ +name: Publish Docker images + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAMESPACE: ${{ github.repository_owner }} + +jobs: + build: + name: ${{ matrix.image }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - image: whatsapp-mcp-go-bridge + context: ./whatsapp-bridge + - image: whatsapp-mcp-go-server + context: ./whatsapp-mcp-server + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,format=short + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.image }} + cache-to: type=gha,mode=max,scope=${{ matrix.image }} + provenance: false + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81dbf69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Project artifacts +whatsapp-bridge/store/ +whatsapp-bridge/whatsapp-bridge +whatsapp-mcp-server/whatsapp-mcp +whatsapp-mcp-server/whatsapp-mcp-server + +# Go build / test output +*.exe +*.test +*.out +coverage.txt + +# Local config / secrets +.env +.env.* +!.env.example + +# Databases +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# Editors / IDEs +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index cba0ec2..b8676be 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Start `whatsapp-bridge` -> then run `whatsapp-mcp-server` in your preferred mode "whatsapp-mcp": { "command": "{{PROJECT_BASE_PATH}}/whatsapp-mcp-server/whatsapp-mcp", "env": { - "WHATSAPP_API_SECRET": "c3VwZXItbG9uZy1yYW5kb20tc3RyaW5nLW1pbmltdW0tb2YtNjQtY2hhcmFjdGVycy15b3UtbmVlZC10by1wYXN0ZS1oZXJl" + "WHATSAPP_API_KEY": "c3VwZXItbG9uZy1yYW5kb20tc3RyaW5nLW1pbmltdW0tb2YtNjQtY2hhcmFjdGVycy15b3UtbmVlZC10by1wYXN0ZS1oZXJl" } } }, @@ -90,36 +90,76 @@ If you're running this project on Windows, be aware that `go-sqlite3` requires * go run main.go # or use this to enabled webhook and http streaming, `WEBHOOK_URL=http://192.168.178.119:5777/sse IS_HTTP=true go run main.go` ``` -OR +### Or run everything in Docker + +The repo ships a `docker-compose.yaml` at the root that brings up three +services: `postgres`, `wa-bridge`, and `wa-mcp`. The MCP server is +started in **HTTP mode** (port 5777) because that is the only mode that +fits a long-running container — see "MCP server: stdio vs HTTP" below +for when to use each. -Simply use dokcer compose to do all the job for you ```bash +# 1. Set the four required vars (in .env at repo root, or in your shell) +cat > .env <` on every `/api/...` call. + +### Step 1: Get a JWT ```bash -curl -X POST http://localhost:8080/auth/login \ - -H "Authorization: Bearer " -# => {"token":""} +curl -X POST \ + -H "Authorization: Bearer $WHATSAPP_API_KEY" \ + http://localhost:8080/auth/login +# {"token":"eyJhbGciOiJIUzI1NiIs..."} ``` -### 2. Call protected endpoints with the JWT +### Step 2: Call the API ```bash -curl -H "Authorization: Bearer " \ - http://localhost:8080/api/messages +TOKEN=eyJhbGciOiJIUzI1NiIs... +curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/chats ``` -The MCP server handles this exchange automatically — you only need to set the -`WHATSAPP_API_SECRET` environment variable to match the bridge's `API_KEY`. +The MCP server does this automatically: configure `WHATSAPP_API_KEY` and it +fetches/refreshes JWTs as needed. + +### Rate limiting + +`/auth/login` is rate-limited per client IP (default: 5 attempts / minute). +Override with `AUTH_LOGIN_RATE=/` (e.g. `10/30s`). Behind a +reverse proxy, terminate rate limiting upstream — the bridge currently uses +`r.RemoteAddr` and does not consult `X-Forwarded-For`. ## Architecture Overview @@ -151,6 +191,8 @@ Claude can access the following tools to interact with WhatsApp: - **send_file**: Send a file (image, video, raw audio, document) to a specified recipient - **send_audio_message**: Send an audio file as a WhatsApp voice message (requires the file to be an .ogg opus file or ffmpeg must be installed) - **download_media**: Download media from a WhatsApp message and get the local file path +- **get_login_status**: Check whether the bridge is connected and logged in to WhatsApp. Returns `{connected, logged_in, pairing_required}`. +- **get_pairing_qr**: Fetch the WhatsApp pairing QR as a PNG image. Returns image content when pairing is required, or a text message when the bridge is already logged in. Useful for completing the initial device-link flow from inside an MCP-aware UI instead of from the terminal. ### Media Handling Features diff --git a/whatsapp-bridge/.env.example b/whatsapp-bridge/.env.example new file mode 100644 index 0000000..c3f8137 --- /dev/null +++ b/whatsapp-bridge/.env.example @@ -0,0 +1,28 @@ +# === REQUIRED === +# Static API key the MCP server uses to call /auth/login. +# Generate with: openssl rand -base64 48 +# Must match WHATSAPP_API_KEY in whatsapp-mcp-server/.env. +WHATSAPP_API_KEY= + +# HMAC secret used to sign 45-min JWTs returned from /auth/login. +# Generate with: openssl rand -base64 48 +WHATSAPP_JWT_SECRET= + +# === REQUIRED for Postgres mode === +# Set IS_POSTGRES=true to use Postgres; otherwise SQLite at ./store/. +IS_POSTGRES=true +POSTGRES_USER= +POSTGRES_PASS= +POSTGRES_HOST= +POSTGRES_PORT=5432 + +# === OPTIONAL === +# Bind address for the REST API server. Empty means all interfaces. +HOST= +PORT=8080 +# Forwarded webhook URL for incoming messages (empty disables). +WEBHOOK_URL= +# Login rate limit. Format: /. Default: 5/1m. +AUTH_LOGIN_RATE= +# Logger level: debug | info | warn | error. Default: info. +LOG_LEVEL=info diff --git a/whatsapp-bridge/.idea/db-forest-config.xml b/whatsapp-bridge/.idea/db-forest-config.xml index a4a030b..9daae3f 100644 --- a/whatsapp-bridge/.idea/db-forest-config.xml +++ b/whatsapp-bridge/.idea/db-forest-config.xml @@ -1,5 +1,12 @@ + + . + ---------------------------------------- + 1:0:4f6200ec-1eae-4549-9d93-2a328888b8d7 + 2:0:ae157347-e4f8-43bd-a8b5-8556d3489f79 + . + diff --git a/whatsapp-bridge/Dockerfile b/whatsapp-bridge/Dockerfile index b2316e5..abf1ccc 100644 --- a/whatsapp-bridge/Dockerfile +++ b/whatsapp-bridge/Dockerfile @@ -17,14 +17,14 @@ RUN go mod download COPY . . # Build -RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GO111MODULE=on go build -a -o whatsapp_mcp_go main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GO111MODULE=on go build -a -o whatsapp-bridge main.go # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details FROM gcr.io/distroless/static-debian12:nonroot WORKDIR /project -COPY --from=builder /project/whatsapp_mcp_go . +COPY --from=builder /project/whatsapp-bridge . USER 65532:65532 -ENTRYPOINT ["/project/whatsapp_mcp_go"] \ No newline at end of file +ENTRYPOINT ["/project/whatsapp-bridge"] \ No newline at end of file diff --git a/whatsapp-bridge/auth/login.go b/whatsapp-bridge/auth/login.go index f8ca348..260cf17 100644 --- a/whatsapp-bridge/auth/login.go +++ b/whatsapp-bridge/auth/login.go @@ -1,9 +1,11 @@ package auth import ( + "crypto/subtle" "encoding/json" - "fmt" + "log/slog" "net/http" + "strconv" "strings" "time" "whatsapp-bridge/config" @@ -17,11 +19,31 @@ type Claims struct { } func LoginHandler(cfg *config.Config) http.HandlerFunc { + limiter, err := newLoginLimiter(cfg.AuthLoginRate) + if err != nil { + // Surface fatal config error at startup by returning a handler that always 500s. + // In practice main() should call newLoginLimiter directly and exit, but keeping + // the existing LoginHandler signature stable avoids a wider refactor in this PR. + slog.Error("invalid AUTH_LOGIN_RATE", "err", err) + return func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "server misconfigured", http.StatusInternalServerError) + } + } return func(w http.ResponseWriter, r *http.Request) { - auth := r.Header.Get("Authorization") + ip := clientIP(r) + lim := limiter.get(ip) + if !lim.Allow() { + retry := retryAfterSeconds(lim) + w.Header().Set("Retry-After", strconv.Itoa(retry)) + slog.Warn("login rate-limited", "remote", ip) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + expected := []byte("Bearer " + cfg.APIKey) + got := []byte(r.Header.Get("Authorization")) - if auth != fmt.Sprintf("Bearer %s", cfg.APIKey) { - fmt.Println("Invalid API key") + if subtle.ConstantTimeCompare(expected, got) != 1 { + slog.Warn("login rejected: bad api key", "remote", r.RemoteAddr) http.Error(w, "Invalid credentials", http.StatusUnauthorized) return } @@ -29,6 +51,8 @@ func LoginHandler(cfg *config.Config) http.HandlerFunc { claims := Claims{ Service: "mcp-server", RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "whatsapp-bridge", + Audience: []string{"whatsapp-mcp-server"}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(45 * time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), }, @@ -37,7 +61,7 @@ func LoginHandler(cfg *config.Config) http.HandlerFunc { token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString(cfg.JWTSecret) if err != nil { - fmt.Println("Failed to sign token:", err) + slog.Error("failed to sign token", "err", err) http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } @@ -57,18 +81,25 @@ func JwtAuthMiddleware(cfg *config.Config, next http.Handler) http.Handler { tokenStr := strings.TrimPrefix(auth, "Bearer ") - token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { - return cfg.JWTSecret, nil - }) + token, err := jwt.ParseWithClaims( + tokenStr, + &Claims{}, + func(token *jwt.Token) (interface{}, error) { + return cfg.JWTSecret, nil + }, + jwt.WithIssuer("whatsapp-bridge"), + jwt.WithAudience("whatsapp-mcp-server"), + jwt.WithValidMethods([]string{"HS256"}), + ) if err != nil { - fmt.Println("Parse error:", err) + slog.Warn("jwt parse error", "err", err, "remote", r.RemoteAddr) http.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } if !token.Valid { - fmt.Println("Token is NOT valid") + slog.Warn("jwt invalid", "remote", r.RemoteAddr) http.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } diff --git a/whatsapp-bridge/auth/ratelimit.go b/whatsapp-bridge/auth/ratelimit.go new file mode 100644 index 0000000..82181c6 --- /dev/null +++ b/whatsapp-bridge/auth/ratelimit.go @@ -0,0 +1,114 @@ +package auth + +import ( + "fmt" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// loginLimiter holds per-IP token buckets for /auth/login. +type loginLimiter struct { + mu sync.Mutex + visitors map[string]*visitor + limit rate.Limit + burst int +} + +type visitor struct { + limiter *rate.Limiter + lastSeen time.Time +} + +// parseRate parses values like "5/1m", "10/30s", "100/1h". +// Returns (rate.Limit, burst, error). +func parseRate(spec string) (rate.Limit, int, error) { + if spec == "" { + return rate.Every(12 * time.Second), 5, nil // default: 5/1m + } + parts := strings.SplitN(spec, "/", 2) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE %q: want /", spec) + } + count, err := strconv.Atoi(parts[0]) + if err != nil || count <= 0 { + return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE count in %q", spec) + } + window, err := time.ParseDuration(parts[1]) + if err != nil || window <= 0 { + return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE window in %q", spec) + } + return rate.Every(window / time.Duration(count)), count, nil +} + +func newLoginLimiter(spec string) (*loginLimiter, error) { + limit, burst, err := parseRate(spec) + if err != nil { + return nil, err + } + l := &loginLimiter{ + visitors: make(map[string]*visitor), + limit: limit, + burst: burst, + } + go l.evictLoop() + return l, nil +} + +func (l *loginLimiter) get(ip string) *rate.Limiter { + l.mu.Lock() + defer l.mu.Unlock() + v, ok := l.visitors[ip] + if !ok { + v = &visitor{limiter: rate.NewLimiter(l.limit, l.burst)} + l.visitors[ip] = v + } + v.lastSeen = time.Now() + return v.limiter +} + +func (l *loginLimiter) evictLoop() { + t := time.NewTicker(time.Minute) + defer t.Stop() + for range t.C { + cutoff := time.Now().Add(-10 * time.Minute) + l.mu.Lock() + for ip, v := range l.visitors { + if v.lastSeen.Before(cutoff) { + delete(l.visitors, ip) + } + } + l.mu.Unlock() + } +} + +// retryAfterSeconds rounds up to the next whole second the limiter expects to refill. +func retryAfterSeconds(lim *rate.Limiter) int { + r := lim.Reserve() + defer r.Cancel() + d := r.Delay() + if d <= 0 { + return 1 + } + secs := int(d / time.Second) + if d%time.Second != 0 { + secs++ + } + return secs +} + +// clientIP extracts the bare IP (no port) from r.RemoteAddr. +// X-Forwarded-For is intentionally NOT consulted (see spec section 2, +// "Known limitation"). A future PR adds trusted-proxy parsing. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/whatsapp-bridge/config/config.go b/whatsapp-bridge/config/config.go index f1fadc0..5dd2dc5 100644 --- a/whatsapp-bridge/config/config.go +++ b/whatsapp-bridge/config/config.go @@ -2,7 +2,9 @@ package config import ( "fmt" + "log/slog" "os" + "strconv" ) type dbConfig struct { @@ -14,41 +16,66 @@ type dbConfig struct { } type Config struct { - DB dbConfig - JWTSecret []byte - APIKey string - WebhookUrl string + DB dbConfig + JWTSecret []byte + APIKey string + WebhookUrl string + Host string + Port int + AuthLoginRate string } func LoadConfig() (*Config, error) { - user, ok := os.LookupEnv("POSTGRES_USER") - if !ok { - return nil, fmt.Errorf("missing POSTGRES_USER") - } - pass, ok := os.LookupEnv("POSTGRES_PASS") - if !ok { - return nil, fmt.Errorf("missing POSTGRES_PASS") - } - host, ok := os.LookupEnv("POSTGRES_HOST") - if !ok { - return nil, fmt.Errorf("missing POSTGRES_HOST") - } - port, ok := os.LookupEnv("POSTGRES_PORT") - if !ok { - return nil, fmt.Errorf("missing POSTGRES_PORT") + isPostgres := os.Getenv("IS_POSTGRES") == "true" + + var user, pass, host, port string + if isPostgres { + var ok bool + user, ok = os.LookupEnv("POSTGRES_USER") + if !ok { + return nil, fmt.Errorf("missing POSTGRES_USER") + } + pass, ok = os.LookupEnv("POSTGRES_PASS") + if !ok { + return nil, fmt.Errorf("missing POSTGRES_PASS") + } + host, ok = os.LookupEnv("POSTGRES_HOST") + if !ok { + return nil, fmt.Errorf("missing POSTGRES_HOST") + } + port, ok = os.LookupEnv("POSTGRES_PORT") + if !ok { + return nil, fmt.Errorf("missing POSTGRES_PORT") + } } - jwtSecret, ok := os.LookupEnv("JWT_SECRET") + jwtSecret, ok := lookupEither("WHATSAPP_JWT_SECRET", "JWT_SECRET") if !ok { - return nil, fmt.Errorf("missing JWT_SECRET") + return nil, fmt.Errorf("missing WHATSAPP_JWT_SECRET") } - apiKey, ok := os.LookupEnv("API_KEY") + apiKey, ok := lookupEither("WHATSAPP_API_KEY", "API_KEY") if !ok { - return nil, fmt.Errorf("missing API_KEY") + return nil, fmt.Errorf("missing WHATSAPP_API_KEY") + } + if err := validateSecret("WHATSAPP_JWT_SECRET (or deprecated alias JWT_SECRET)", jwtSecret); err != nil { + return nil, err + } + if err := validateSecret("WHATSAPP_API_KEY (or deprecated alias API_KEY)", apiKey); err != nil { + return nil, err } webhookUrl := os.Getenv("WEBHOOK_URL") - isPostgres := os.Getenv("IS_POSTGRES") == "true" + serverHost := os.Getenv("HOST") + serverPort := 8080 + if v, ok := os.LookupEnv("PORT"); ok { + p, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("invalid PORT %q: %w", v, err) + } + serverPort = p + } + + authLoginRate := os.Getenv("AUTH_LOGIN_RATE") // parsed in auth package; empty -> default return &Config{ DB: dbConfig{ @@ -58,8 +85,53 @@ func LoadConfig() (*Config, error) { Port: port, IsPostgres: isPostgres, }, - JWTSecret: []byte(jwtSecret), - APIKey: apiKey, - WebhookUrl: webhookUrl, + JWTSecret: []byte(jwtSecret), + APIKey: apiKey, + WebhookUrl: webhookUrl, + Host: serverHost, + Port: serverPort, + AuthLoginRate: authLoginRate, }, nil } + +const minSecretLen = 32 + +// knownPlaceholders are the example values shipped in docker-compose.yaml. +// Operators who forget to override them must see startup fail loudly. +var knownPlaceholders = []string{ + "c3VwZXItbG9uZy1yYW5kb20tc3RyaW5nLW1pbmltdW0tb2YtNjQtY2hhcmFjdGVycy15b3UtbmVlZC10by1wYXN0ZS1oZXJl", + "YW5vdGhlci1zdXBlci1sb25nLXJhbmRvbS1zdHJpbmctbWluaW11bS1vZi02NC1jaGFyYWN0ZXJzLXlvdS1uZWVkLXRvLXBhc3RlLWhlcmU=", +} + +func validateSecret(name, value string) error { + for _, ph := range knownPlaceholders { + if value == ph { + return fmt.Errorf("%s is set to a placeholder value; generate a real one with `openssl rand -base64 48`", name) + } + } + if len(value) < minSecretLen { + return fmt.Errorf("%s is too short (%d chars, need ≥%d)", name, len(value), minSecretLen) + } + return nil +} + +// envWarnFn is replaced in tests; in production it logs via slog. +var envWarnFn = func(msg string, args ...any) { + slog.Warn(msg, args...) +} + +// lookupEither returns the value for `primary` if set, otherwise falls back +// to `deprecated` and emits a deprecation warning. Returns ("", false) only +// when neither is set. +func lookupEither(primary, deprecated string) (string, bool) { + if v, ok := os.LookupEnv(primary); ok { + return v, true + } + if v, ok := os.LookupEnv(deprecated); ok { + envWarnFn("env var is deprecated, use the new name", + "deprecated", deprecated, + "use_instead", primary) + return v, true + } + return "", false +} diff --git a/whatsapp-bridge/docker-compose.yaml b/whatsapp-bridge/docker-compose.yaml index 933e7ff..e5218d8 100644 --- a/whatsapp-bridge/docker-compose.yaml +++ b/whatsapp-bridge/docker-compose.yaml @@ -1,26 +1,40 @@ services: - wa: - build: - context: . - dockerfile: Dockerfile - container_name: wa + postgres: + image: postgres:16 + container_name: postgres restart: always environment: + POSTGRES_USER: "${POSTGRES_USER:?set POSTGRES_USER in environment or .env}" + POSTGRES_PASSWORD: "${POSTGRES_PASS:?set POSTGRES_PASS in environment or .env}" + POSTGRES_DB: whatsapp + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + wa-bridge: + build: + context: ./whatsapp-bridge + dockerfile: Dockerfile + container_name: wa-bridge + restart: always + environment: + WHATSAPP_API_KEY: "${WHATSAPP_API_KEY:?set WHATSAPP_API_KEY in environment or .env}" + WHATSAPP_JWT_SECRET: "${WHATSAPP_JWT_SECRET:?set WHATSAPP_JWT_SECRET in environment or .env}" IS_POSTGRES: "true" - POSTGRES_USER: "test" - POSTGRES_PASS: "test" - POSTGRES_HOST: "postgres" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASS: "${POSTGRES_PASS}" + POSTGRES_HOST: postgres POSTGRES_PORT: "5432" - # Replace with two strong, independent random strings before bringing the stack up. - # Suggested: openssl rand -base64 64 | tr -d '\n=' | tr '/+' '_-' | head -c 64 - API_KEY: "CHANGE_ME_TO_A_LONG_RANDOM_STRING_AT_LEAST_64_CHARS_______________" - JWT_SECRET: "CHANGE_ME_TO_A_DIFFERENT_LONG_RANDOM_STRING_AT_LEAST_64_CHARS____" - # Optional: set this to forward every inbound message to a webhook (e.g. n8n). - # WEBHOOK_URL: "http://your-host:5678/webhook/whatsapp" + LOG_LEVEL: "${LOG_LEVEL:-info}" + AUTH_LOGIN_RATE: "${AUTH_LOGIN_RATE:-}" + WEBHOOK_URL: "${WEBHOOK_URL:-}" + HOST: "${HOST:-}" + PORT: "${PORT:-8080}" ports: - "8080:8080" volumes: - - media:/project/store + - bridge-store:/project/store depends_on: - postgres deploy: @@ -31,19 +45,32 @@ services: reservations: cpus: "0.5" memory: "64M" - postgres: - image: postgres:16 - container_name: postgres + + wa-mcp: + build: + context: ./whatsapp-mcp-server + dockerfile: Dockerfile + container_name: wa-mcp restart: always environment: - POSTGRES_USER: "test" - POSTGRES_PASSWORD: "test" - POSTGRES_DB: "whatsapp" + WHATSAPP_API_KEY: "${WHATSAPP_API_KEY:?set WHATSAPP_API_KEY in environment or .env}" + API_BASE_URL: "http://wa-bridge:8080/api" + IS_HTTP: "true" + HTTP_BASE_URL: "0.0.0.0:5777" + LOG_LEVEL: "${LOG_LEVEL:-info}" ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data + - "5777:5777" + depends_on: + - wa-bridge + deploy: + resources: + limits: + cpus: "0.5" + memory: "64M" + reservations: + cpus: "0.25" + memory: "32M" volumes: - media: pgdata: + bridge-store: \ No newline at end of file diff --git a/whatsapp-bridge/go.mod b/whatsapp-bridge/go.mod index 20cf1c7..e85ac71 100644 --- a/whatsapp-bridge/go.mod +++ b/whatsapp-bridge/go.mod @@ -4,10 +4,12 @@ go 1.25.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/lib/pq v1.11.2 - github.com/mattn/go-sqlite3 v1.14.34 + github.com/lib/pq v1.12.3 + github.com/mattn/go-sqlite3 v1.14.44 github.com/mdp/qrterminal v1.0.1 - go.mau.fi/whatsmeow v0.0.0-20260305215846-fc65416c22c4 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + go.mau.fi/whatsmeow v0.0.0-20260511155711-eb05d94dea7d + golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.11 ) @@ -18,16 +20,17 @@ require ( github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect - github.com/rs/zerolog v1.34.0 // indirect - github.com/vektah/gqlparser/v2 v2.5.32 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/vektah/gqlparser/v2 v2.5.33 // indirect go.mau.fi/libsignal v0.2.1 // indirect - go.mau.fi/util v0.9.6 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect + go.mau.fi/util v0.9.8 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/whatsapp-bridge/go.sum b/whatsapp-bridge/go.sum index 5f7f38a..c49998d 100644 --- a/whatsapp-bridge/go.sum +++ b/whatsapp-bridge/go.sum @@ -24,6 +24,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -31,49 +33,73 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mdp/qrterminal v1.0.1 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c= github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E= +github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= -go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= -go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= +go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8= +go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0= go.mau.fi/whatsmeow v0.0.0-20260305215846-fc65416c22c4 h1:FGA3NtCVNeCJ+C+KBg1pODsrfxC/trM3RHFWIeY7y4c= go.mau.fi/whatsmeow v0.0.0-20260305215846-fc65416c22c4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= +go.mau.fi/whatsmeow v0.0.0-20260511155711-eb05d94dea7d h1:GBtuMd+MvpxZ0hII0xWzc9N4z1BPhph7aDZb6EizhO4= +go.mau.fi/whatsmeow v0.0.0-20260511155711-eb05d94dea7d/go.mod h1:ijfkzOXauA/Vz/htXEMfOAJSUgglribW5oQeYC9tSSg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/whatsapp-bridge/logger/logger.go b/whatsapp-bridge/logger/logger.go new file mode 100644 index 0000000..ccff329 --- /dev/null +++ b/whatsapp-bridge/logger/logger.go @@ -0,0 +1,24 @@ +package logger + +import ( + "log/slog" + "os" + "strings" +) + +// New returns a JSON slog.Logger configured at the given level. +// Accepted levels: "debug", "info", "warn", "error". Anything else falls back to info. +func New(level string) *slog.Logger { + var lvl slog.Level + switch strings.ToLower(strings.TrimSpace(level)) { + case "debug": + lvl = slog.LevelDebug + case "warn", "warning": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})) +} diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index 6337870..74f3ae7 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log" + "log/slog" "math" "math/rand" "net/http" @@ -18,10 +19,13 @@ import ( "regexp" "strconv" "strings" + "sync" "syscall" "time" "whatsapp-bridge/auth" "whatsapp-bridge/config" + bridgelogger "whatsapp-bridge/logger" + "whatsapp-bridge/wastate" "go.mau.fi/whatsmeow/proto/waCompanionReg" "go.mau.fi/whatsmeow/socket" @@ -29,6 +33,7 @@ import ( _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" "github.com/mdp/qrterminal" + qrcode "github.com/skip2/go-qrcode" "bytes" @@ -123,6 +128,44 @@ func openDatabase(dbName string) (*sql.DB, error) { return sql.Open("sqlite3", "file:store/messages.db?_foreign_keys=on") } +// validateMediaPath sanitize media path +func validateMediaPath(mediaPath string) (string, error) { + if mediaPath == "" { + return "", fmt.Errorf("empty media path") + } + + // Allowed media directory + baseDir := "./media" + + absBaseDir, err := filepath.Abs(baseDir) + if err != nil { + return "", err + } + + // Reject absolute paths + if filepath.IsAbs(mediaPath) { + return "", fmt.Errorf("absolute paths are not allowed") + } + + // Clean traversal sequences + cleanPath := filepath.Clean(mediaPath) + + fullPath := filepath.Join(absBaseDir, cleanPath) + + absPath, err := filepath.Abs(fullPath) + if err != nil { + return "", err + } + + // Ensure resolved path stays inside media directory + if !strings.HasPrefix(absPath, absBaseDir+string(os.PathSeparator)) && + absPath != absBaseDir { + return "", fmt.Errorf("path traversal detected") + } + + return absPath, nil +} + // NewMessageStore Initialize message store func NewMessageStore() (*MessageStore, error) { if err := os.MkdirAll("store", 0755); err != nil { @@ -181,6 +224,231 @@ func (store *MessageStore) Close() error { return store.db.Close() } +// normalizeUserJID converts a LID JID (xxxx@lid) into a phone-number JID (xxxx@s.whatsapp.net) +// using the whatsmeow LID mapping store. Non-LID JIDs are returned unchanged. +func normalizeUserJID(client *whatsmeow.Client, jid types.JID) types.JID { + if client == nil || client.Store == nil || client.Store.LIDs == nil { + return jid + } + + // Only normalize hidden-user server (@lid) + if jid.Server != types.HiddenUserServer { + return jid + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + pn, err := client.Store.LIDs.GetPNForLID(ctx, jid) + if err != nil || pn.IsEmpty() { + return jid + } + + return pn +} + +// migrateLIDChatsToPhoneJIDs merges chats stored under @lid +// into their corresponding @s.whatsapp.net chats. +// +// This is idempotent and safe to run on every startup. +// +// Migration order: +// +// 1. Create/upsert PN chat +// 2. Move messages to PN chat +// 3. Delete leftover duplicate messages +// 4. Delete old LID chat +func migrateLIDChatsToPhoneJIDs( + client *whatsmeow.Client, + store *MessageStore, + logger waLog.Logger, + isPostgresDB bool, +) { + if client == nil || store == nil || store.db == nil { + return + } + + db := store.db + + var query string + arg := "%@" + types.HiddenUserServer + + if isPostgresDB { + query = ` + SELECT jid, name, last_message_time + FROM chats + WHERE jid LIKE $1 + ` + } else { + query = ` + SELECT jid, name, last_message_time + FROM chats + WHERE jid LIKE ? + ` + } + + rows, err := db.Query(query, arg) + if err != nil { + logger.Errorf("LID migration: failed listing chats: %v", err) + return + } + defer rows.Close() + + type lidChat struct { + JID string + Name string + LastMessageTime time.Time + } + + var chats []lidChat + + for rows.Next() { + var c lidChat + + if err := rows.Scan(&c.JID, &c.Name, &c.LastMessageTime); err != nil { + logger.Warnf("LID migration: scan failed: %v", err) + continue + } + + chats = append(chats, c) + } + + if len(chats) == 0 { + return + } + + logger.Infof("LID migration: found %d @lid chats", len(chats)) + + merged := 0 + skipped := 0 + + for _, c := range chats { + + tx, err := db.Begin() + if err != nil { + logger.Warnf("LID migration: tx begin failed for %s: %v", c.JID, err) + skipped++ + continue + } + + commit := false + defer func() { + if !commit { + _ = tx.Rollback() + } + }() + + lidJID, parseErr := types.ParseJID(c.JID) + if parseErr != nil { + logger.Warnf("LID migration: invalid jid %s: %v", c.JID, parseErr) + _ = tx.Rollback() + skipped++ + continue + } + + pnJID := normalizeUserJID(client, lidJID) + + if pnJID.Server != types.DefaultUserServer { + _ = tx.Rollback() + skipped++ + continue + } + + pnStr := pnJID.String() + + var upsertQuery string + + if isPostgresDB { + upsertQuery = ` + INSERT INTO chats (jid, name, last_message_time) + VALUES ($1, $2, $3) + ON CONFLICT (jid) + DO UPDATE SET + name = COALESCE(NULLIF(chats.name, ''), EXCLUDED.name), + last_message_time = GREATEST( + chats.last_message_time, + EXCLUDED.last_message_time + ) + ` + } else { + upsertQuery = ` + INSERT INTO chats (jid, name, last_message_time) + VALUES (?, ?, ?) + ON CONFLICT(jid) + DO UPDATE SET + name = COALESCE(NULLIF(chats.name, ''), excluded.name), + last_message_time = MAX( + chats.last_message_time, + excluded.last_message_time + ) + ` + } + + if _, err = tx.Exec(upsertQuery, pnStr, c.Name, c.LastMessageTime); err != nil { + logger.Warnf("LID migration: upsert failed %s -> %s: %v", c.JID, pnStr, err) + _ = tx.Rollback() + skipped++ + continue + } + + var moveMessagesQuery string + + if isPostgresDB { + moveMessagesQuery = ` + UPDATE messages + SET chat_jid = $1 + WHERE chat_jid = $2 + ` + } else { + moveMessagesQuery = ` + UPDATE messages + SET chat_jid = ? + WHERE chat_jid = ? + ` + } + + if _, err = tx.Exec(moveMessagesQuery, pnStr, c.JID); err != nil { + logger.Warnf("LID migration: move messages failed %s -> %s: %v", c.JID, pnStr, err) + _ = tx.Rollback() + skipped++ + continue + } + + var deleteChatQuery string + + if isPostgresDB { + deleteChatQuery = `DELETE FROM chats WHERE jid = $1` + } else { + deleteChatQuery = `DELETE FROM chats WHERE jid = ?` + } + + if _, err = tx.Exec(deleteChatQuery, c.JID); err != nil { + logger.Warnf("LID migration: delete old chat failed %s: %v", c.JID, err) + _ = tx.Rollback() + skipped++ + continue + } + + if err = tx.Commit(); err != nil { + logger.Warnf("LID migration: commit failed %s: %v", c.JID, err) + _ = tx.Rollback() + skipped++ + continue + } + + commit = true + + logger.Infof("LID migration: merged %s -> %s", c.JID, pnStr) + merged++ + } + + logger.Infof( + "LID migration complete: %d merged, %d skipped", + merged, + skipped, + ) +} + // StoreChat Store a chat in the database func (store *MessageStore) StoreChat(jid, name string, lastMessageTime time.Time) error { if isPostgres { @@ -394,7 +662,11 @@ func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message str msg := &waE2E.Message{} if mediaPath != "" { - mediaData, err := os.ReadFile(mediaPath) + validatedPath, err := validateMediaPath(mediaPath) + if err != nil { + return false, fmt.Sprintf("Invalid media path: %v", err) + } + mediaData, err := os.ReadFile(validatedPath) if err != nil { return false, fmt.Sprintf("Error reading media file: %v", err) } @@ -441,7 +713,7 @@ func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message str return false, fmt.Sprintf("Error uploading media: %v", err) } - fmt.Println("Media uploaded", resp) + slog.Info("media uploaded", "response", resp) switch mediaType { case whatsmeow.MediaImage: @@ -468,7 +740,7 @@ func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message str return false, fmt.Sprintf("Failed to analyze Ogg Opus file: %v", err) } } else { - fmt.Printf("Not an Ogg Opus file: %s\n", mimeType) + slog.Warn("not an Ogg Opus file", "mime_type", mimeType) } msg.AudioMessage = &waE2E.AudioMessage{ @@ -603,8 +875,8 @@ func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *ev } defer resp.Body.Close() }() - chatJID := msg.Info.Chat.String() - sender := msg.Info.Sender.User + chatJID := normalizeUserJID(client, msg.Info.Chat).String() + sender := normalizeUserJID(client, msg.Info.Sender).User name := GetChatName(client, messageStore, msg.Info.Chat, chatJID, nil, sender, logger) @@ -647,9 +919,9 @@ func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *ev } if mediaType != "" { - fmt.Printf("[%s] %s %s: [%s: %s] %s\n", timestamp, direction, sender, mediaType, filename, content) + slog.Info("message", "ts", timestamp, "direction", direction, "sender", sender, "media_type", mediaType, "filename", filename, "content", content) } else if content != "" { - fmt.Printf("[%s] %s %s: %s\n", timestamp, direction, sender, content) + slog.Info("message", "ts", timestamp, "direction", direction, "sender", sender, "content", content) } } } @@ -814,7 +1086,7 @@ func downloadMedia(client *whatsmeow.Client, messageStore *MessageStore, message return false, "", "", "", fmt.Errorf("incomplete media information for download") } - fmt.Printf("Attempting to download media for message %s in chat %s...\n", messageID, chatJID) + slog.Info("attempting to download media", "message_id", messageID, "chat_jid", chatJID) directPath := extractDirectPathFromURL(url) @@ -851,7 +1123,7 @@ func downloadMedia(client *whatsmeow.Client, messageStore *MessageStore, message return false, "", "", "", fmt.Errorf("failed to save media file: %v", err) } - fmt.Printf("Successfully downloaded %s media to %s (%d bytes)\n", mediaType, absPath, len(mediaData)) + slog.Info("successfully downloaded media", "media_type", mediaType, "path", absPath, "bytes", len(mediaData)) return true, mediaType, filename, absPath, nil } @@ -872,7 +1144,7 @@ func extractDirectPathFromURL(url string) string { } // Start a REST API server to expose the WhatsApp client functionality -func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port int, cfg *config.Config) { +func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, cfg *config.Config, state *wastate.State) { apiMux := http.NewServeMux() // Send message @@ -898,10 +1170,10 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port return } - fmt.Println("Received request to send message", req.Message, req.MediaPath) + slog.Info("received request to send message", "message", req.Message, "media_path", req.MediaPath) success, message := sendWhatsAppMessage(client, req.Recipient, req.Message, req.MediaPath) - fmt.Println("Message sent", success, message) + slog.Info("message sent", "success", success, "message", message) w.Header().Set("Content-Type", "application/json") if !success { @@ -1213,17 +1485,46 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port }) }) + apiMux.HandleFunc("/auth/status", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + respondJSON(w, http.StatusOK, map[string]any{ + "connected": state.Connected(), + "logged_in": state.LoggedIn(), + "pairing_required": state.PairingRequired(), + "wa_version": state.WAVersion(), + }) + }) + + apiMux.HandleFunc("/auth/pairing-qr", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + png := state.PairingQRPNG() + if png == nil { + http.Error(w, "no pairing QR available; client is logged in or has not started pairing yet", http.StatusGone) + return + } + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(png) + }) + // Authentication protected := auth.JwtAuthMiddleware(cfg, apiMux) http.Handle("/api/", http.StripPrefix("/api", protected)) http.Handle("/auth/login", auth.LoginHandler(cfg)) - serverAddr := fmt.Sprintf(":%d", port) - fmt.Printf("Starting REST API server on %s...\n", serverAddr) + serverAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + slog.Info("starting REST API server", "addr", serverAddr) go func() { if err := http.ListenAndServe(serverAddr, nil); err != nil { - fmt.Printf("REST API server error: %v\n", err) + slog.Error("rest api server error", "err", err) } }() } @@ -1308,7 +1609,7 @@ func GetChatName(client *whatsmeow.Client, messageStore *MessageStore, jid types // Handle history sync events func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, historySync *events.HistorySync, logger waLog.Logger) { - fmt.Printf("Received history sync event with %d conversations\n", len(historySync.Data.Conversations)) + slog.Info("received history sync event", "conversations", len(historySync.Data.Conversations)) syncedCount := 0 for _, conversation := range historySync.Data.Conversations { @@ -1323,6 +1624,8 @@ func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, his logger.Warnf("Failed to parse JID %s: %v", chatJID, err) continue } + jid = normalizeUserJID(client, jid) + chatJID = jid.String() name := GetChatName(client, messageStore, jid, chatJID, conversation, "", logger) @@ -1377,7 +1680,11 @@ func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, his isFromMe = *msg.Message.Key.FromMe } if !isFromMe && msg.Message.Key.Participant != nil && *msg.Message.Key.Participant != "" { - sender = *msg.Message.Key.Participant + if pJid, err := types.ParseJID(*msg.Message.Key.Participant); err == nil { + sender = normalizeUserJID(client, pJid).User + } else { + sender = *msg.Message.Key.Participant + } } else if isFromMe { sender = client.Store.ID.User } else { @@ -1430,29 +1737,29 @@ func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, his } } - fmt.Printf("History sync complete. Stored %d messages.\n", syncedCount) + slog.Info("history sync complete", "stored_messages", syncedCount) } // Request history sync from the server func requestHistorySync(client *whatsmeow.Client) { if client == nil { - fmt.Println("Client is not initialized. Cannot request history sync.") + slog.Error("client is not initialized, cannot request history sync") return } if !client.IsConnected() { - fmt.Println("Client is not connected. Please ensure you are connected to WhatsApp first.") + slog.Warn("client is not connected to whatsapp") return } if client.Store.ID == nil { - fmt.Println("Client is not logged in. Please scan the QR code first.") + slog.Warn("client is not logged in, please scan the qr code") return } historyMsg := client.BuildHistorySyncRequest(nil, 100) if historyMsg == nil { - fmt.Println("Failed to build history sync request.") + slog.Error("failed to build history sync request") return } @@ -1462,9 +1769,9 @@ func requestHistorySync(client *whatsmeow.Client) { }, historyMsg) if err != nil { - fmt.Printf("Failed to request history sync: %v\n", err) + slog.Error("failed to request history sync", "err", err) } else { - fmt.Println("History sync requested. Waiting for server response...") + slog.Info("history sync requested, waiting for server response") } } @@ -1515,7 +1822,7 @@ func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) { preSkip = binary.LittleEndian.Uint16(pageData[headPos+10 : headPos+12]) sampleRate = binary.LittleEndian.Uint32(pageData[headPos+12 : headPos+16]) foundOpusHead = true - fmt.Printf("Found OpusHead: sampleRate=%d, preSkip=%d\n", sampleRate, preSkip) + slog.Info("found OpusHead", "sample_rate", sampleRate, "pre_skip", preSkip) } } } @@ -1528,16 +1835,15 @@ func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) { } if !foundOpusHead { - fmt.Println("Warning: OpusHead not found, using default values") + slog.Warn("opushead not found, using default values") } if lastGranule > 0 { durationSeconds := float64(lastGranule-uint64(preSkip)) / float64(sampleRate) duration = uint32(math.Ceil(durationSeconds)) - fmt.Printf("Calculated Opus duration from granule: %f seconds (lastGranule=%d)\n", - durationSeconds, lastGranule) + slog.Info("calculated Opus duration from granule", "duration_seconds", durationSeconds, "last_granule", lastGranule) } else { - fmt.Println("Warning: No valid granule position found, using estimation") + slog.Warn("no valid granule position found, using estimation") durationEstimate := float64(len(data)) / 2000.0 duration = uint32(durationEstimate) } @@ -1550,8 +1856,7 @@ func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) { waveform = placeholderWaveform(duration) - fmt.Printf("Ogg Opus analysis: size=%d bytes, calculated duration=%d sec, waveform=%d bytes\n", - len(data), duration, len(waveform)) + slog.Info("ogg opus analysis complete", "size_bytes", len(data), "duration_sec", duration, "waveform_bytes", len(waveform)) return duration, waveform, nil } @@ -2381,8 +2686,11 @@ func main() { return } + slog.SetDefault(bridgelogger.New(os.Getenv("LOG_LEVEL"))) + cfg, err := config.LoadConfig() if err != nil { + logger.Errorf("Failed to load config: %v", err) return } @@ -2412,11 +2720,14 @@ func main() { } } + state := wastate.New() + version, err := CustomGetLatestVersion(context.Background(), nil) if err != nil { logger.Errorf("Failed to retrieve current WhatsApp Web client Version") } else { store.SetWAVersion(*version) + state.SetWAVersion(fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2])) logger.Infof("WhatsApp Web Client Version: %d.%d.%d\n", version[0], version[1], version[2]) } client := whatsmeow.NewClient(deviceStore, logger) @@ -2435,6 +2746,12 @@ func main() { } defer messageStore.Close() + state.SetLoggedIn(client.Store.ID != nil) // existing session means already logged in + + const maxOutdatedRetries = 3 + var outdatedRetries int + var outdatedRetriesMu sync.Mutex + client.AddEventHandler(func(evt interface{}) { switch v := evt.(type) { case *events.Message: @@ -2445,58 +2762,112 @@ func main() { case *events.Connected: logger.Infof("Connected to WhatsApp") + state.SetConnected(true) + state.SetLoggedIn(true) + state.ClearPairingQR() + outdatedRetriesMu.Lock() + outdatedRetries = 0 + outdatedRetriesMu.Unlock() + + case *events.Disconnected: + logger.Warnf("Disconnected from WhatsApp") + state.SetConnected(false) case *events.LoggedOut: logger.Warnf("Device logged out, please scan QR code to log in again") + state.SetLoggedIn(false) + state.SetConnected(false) + + case *events.ClientOutdated: + outdatedRetriesMu.Lock() + outdatedRetries++ + n := outdatedRetries + outdatedRetriesMu.Unlock() + state.SetConnected(false) + if n > maxOutdatedRetries { + slog.Error("client outdated: exceeded retry budget; whatsmeow library likely needs a real upgrade", + "retries", n, "max", maxOutdatedRetries) + return + } + slog.Warn("client outdated (405); refreshing wa version and reconnecting", + "attempt", n, "max", maxOutdatedRetries) + go func() { + time.Sleep(5 * time.Second) + newVersion, err := CustomGetLatestVersion(context.Background(), nil) + if err != nil { + slog.Error("failed to refresh wa version", "err", err) + return + } + store.SetWAVersion(*newVersion) + state.SetWAVersion(fmt.Sprintf("%d.%d.%d", newVersion[0], newVersion[1], newVersion[2])) + slog.Info("applied refreshed wa version, attempting reconnect", "version", state.WAVersion()) + if err := client.Connect(); err != nil { + slog.Error("reconnect after wa version refresh failed", "err", err) + } + }() } }) - connected := make(chan bool, 1) + migrateLIDChatsToPhoneJIDs(client, messageStore, logger, cfg.DB.IsPostgres) - if client.Store.ID == nil { - qrChan, _ := client.GetQRChannel(context.Background()) - err = client.Connect() - if err != nil { - logger.Errorf("Failed to connect: %v", err) - return - } + // REST server comes up first so /api/auth/status and /api/auth/pairing-qr + // are reachable during pairing. WhatsApp connect runs concurrently below. + startRESTServer(client, messageStore, cfg, state) - for evt := range qrChan { - if evt.Event == "code" { - fmt.Println("\nScan this QR code with your WhatsApp app:") - qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) - } else if evt.Event == "success" { - connected <- true - break + // Periodically refresh the WhatsApp Web client version so reconnects + // after transient drops use a current version string. Only the next + // connection picks up the refreshed value; the active session is unaffected. + go func() { + t := time.NewTicker(6 * time.Hour) + defer t.Stop() + for range t.C { + v, err := CustomGetLatestVersion(context.Background(), nil) + if err != nil { + slog.Warn("periodic wa version refresh failed", "err", err) + continue + } + store.SetWAVersion(*v) + next := fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2]) + if next != state.WAVersion() { + slog.Info("wa version updated by periodic refresh", "from", state.WAVersion(), "to", next) + state.SetWAVersion(next) } } + }() - select { - case <-connected: - fmt.Println("\nSuccessfully connected and authenticated!") - case <-time.After(3 * time.Minute): - logger.Errorf("Timeout waiting for QR code scan") - return + // Pair / connect to WhatsApp in a goroutine so main can block on signals. + go func() { + if client.Store.ID == nil { + qrChan, _ := client.GetQRChannel(context.Background()) + if err := client.Connect(); err != nil { + logger.Errorf("Failed to connect: %v", err) + return + } + for evt := range qrChan { + switch evt.Event { + case "code": + fmt.Println("\nScan this QR code with your WhatsApp app:") + qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) + if png, err := qrcode.Encode(evt.Code, qrcode.Medium, 256); err == nil { + state.SetPairingQRPNG(png) + } else { + slog.Warn("failed to encode pairing qr as png", "err", err) + } + case "success": + fmt.Println("\nSuccessfully connected and authenticated!") + return + case "timeout": + logger.Errorf("Pairing QR timeout") + return + } + } + } else { + if err := client.Connect(); err != nil { + logger.Errorf("Failed to connect: %v", err) + return + } } - } else { - err = client.Connect() - if err != nil { - logger.Errorf("Failed to connect: %v", err) - return - } - connected <- true - } - - time.Sleep(2 * time.Second) - - if !client.IsConnected() { - logger.Errorf("Failed to establish stable connection") - return - } - - fmt.Println("\n✓ Connected to WhatsApp! Type 'help' for commands.") - - startRESTServer(client, messageStore, 8080, cfg) + }() exitChan := make(chan os.Signal, 1) signal.Notify(exitChan, syscall.SIGINT, syscall.SIGTERM) diff --git a/whatsapp-bridge/wastate/wastate.go b/whatsapp-bridge/wastate/wastate.go new file mode 100644 index 0000000..a5d40f4 --- /dev/null +++ b/whatsapp-bridge/wastate/wastate.go @@ -0,0 +1,88 @@ +package wastate + +import "sync" + +// State tracks the WhatsApp client's connection + login state, the +// current pairing QR PNG bytes (populated only while pairing is required), +// and the most recent WhatsApp Web client version string applied to the store. +type State struct { + mu sync.RWMutex + connected bool + loggedIn bool + pairingQRPNG []byte + waVersion string +} + +func New() *State { + return &State{} +} + +func (s *State) Connected() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.connected +} + +func (s *State) LoggedIn() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.loggedIn +} + +// PairingQRPNG returns a copy of the current QR PNG bytes, or nil if +// pairing is not required. Returning a copy avoids aliasing issues if +// the state is updated concurrently. +func (s *State) PairingQRPNG() []byte { + s.mu.RLock() + defer s.mu.RUnlock() + if s.pairingQRPNG == nil { + return nil + } + out := make([]byte, len(s.pairingQRPNG)) + copy(out, s.pairingQRPNG) + return out +} + +// PairingRequired returns true when the client is not logged in AND +// a pairing QR is currently available. +func (s *State) PairingRequired() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return !s.loggedIn && s.pairingQRPNG != nil +} + +func (s *State) SetConnected(v bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.connected = v +} + +func (s *State) SetLoggedIn(v bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.loggedIn = v +} + +func (s *State) SetPairingQRPNG(b []byte) { + s.mu.Lock() + defer s.mu.Unlock() + s.pairingQRPNG = b +} + +func (s *State) ClearPairingQR() { + s.mu.Lock() + defer s.mu.Unlock() + s.pairingQRPNG = nil +} + +func (s *State) WAVersion() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.waVersion +} + +func (s *State) SetWAVersion(v string) { + s.mu.Lock() + defer s.mu.Unlock() + s.waVersion = v +} diff --git a/whatsapp-mcp-server/.env.example b/whatsapp-mcp-server/.env.example new file mode 100644 index 0000000..ac54fa3 --- /dev/null +++ b/whatsapp-mcp-server/.env.example @@ -0,0 +1,15 @@ +# === REQUIRED === +# Same value as WHATSAPP_API_KEY in the bridge's .env. +# The MCP server presents this to the bridge's /auth/login endpoint +# to obtain a short-lived JWT for /api/* calls. +# (WHATSAPP_API_SECRET is also accepted as a deprecated alias.) +WHATSAPP_API_KEY= + +# === OPTIONAL === +# Bridge API base URL. Default: http://localhost:8080/api. +API_BASE_URL=http://localhost:8080/api + +# === HTTP mode (optional) === +# Set IS_HTTP=true to expose MCP over HTTP instead of stdio. +IS_HTTP=false +HTTP_BASE_URL=0.0.0.0:5777 diff --git a/whatsapp-mcp-server/Dockerfile b/whatsapp-mcp-server/Dockerfile new file mode 100644 index 0000000..bafd388 --- /dev/null +++ b/whatsapp-mcp-server/Dockerfile @@ -0,0 +1,30 @@ +# Build the manager binary +FROM golang:1.25.7 AS builder + +ARG TARGETARCH +ARG TARGETOS + +WORKDIR /project + +# Copy the Go Modules manifests +COPY go.mod go.mod +COPY go.sum go.sum +# cache deps before building and copying source so that we don't need to re-download as much +# and so that source changes don't invalidate our downloaded layer +RUN go mod download + +# Copy the go source +COPY . . + +# Build +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GO111MODULE=on go build -a -o whatsapp-bridge main.go + +# Use distroless as minimal base image to package the manager binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /project +COPY --from=builder /project/whatsapp-bridge . + +USER 65532:65532 + +ENTRYPOINT ["/project/whatsapp-bridge"] diff --git a/whatsapp-mcp-server/helpers/api_auth.go b/whatsapp-mcp-server/helpers/api_auth.go index b2c7349..3f60ece 100644 --- a/whatsapp-mcp-server/helpers/api_auth.go +++ b/whatsapp-mcp-server/helpers/api_auth.go @@ -14,12 +14,25 @@ import ( ) var ( - apiSecret = ReadEnv("WHATSAPP_API_SECRET", "") + apiKey = readApiKeyEnv() jwtToken string tokenMutex sync.Mutex tokenExpiresAt time.Time ) +func readApiKeyEnv() string { + if v := ReadEnv("WHATSAPP_API_KEY", ""); v != "" { + return v + } + if v := ReadEnv("WHATSAPP_API_SECRET", ""); v != "" { + slog.Warn("env var is deprecated, use the new name", + "deprecated", "WHATSAPP_API_SECRET", + "use_instead", "WHATSAPP_API_KEY") + return v + } + return "" +} + // GetOrRefreshJwtToken returns a valid JWT or fetches a new one func GetOrRefreshJwtToken() (string, error) { tokenMutex.Lock() @@ -35,7 +48,7 @@ func GetOrRefreshJwtToken() (string, error) { if err != nil { return "", err } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiSecret)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) diff --git a/whatsapp-mcp-server/helpers/mcp_tool.go b/whatsapp-mcp-server/helpers/mcp_tool.go index cc1ba71..73660ca 100644 --- a/whatsapp-mcp-server/helpers/mcp_tool.go +++ b/whatsapp-mcp-server/helpers/mcp_tool.go @@ -82,6 +82,16 @@ func InitMcpTool() { Description: "Download media from a WhatsApp message and return local file path.", }, downloadMediaHandler) + mcp.AddTool[getLoginStatusInput, any](server, &mcp.Tool{ + Name: "get_login_status", + Description: "Check whether the WhatsApp bridge is connected and logged in. Returns {connected, logged_in, pairing_required}.", + }, getLoginStatusHandler) + + mcp.AddTool[getPairingQrInput, any](server, &mcp.Tool{ + Name: "get_pairing_qr", + Description: "Fetch the WhatsApp pairing QR as a PNG image. Returns image content when pairing is required; returns a text message when the bridge is already logged in or pairing has not started.", + }, getPairingQrHandler) + isHttp := strings.ToLower(ReadEnv("IS_HTTP", "false")) == "true" || strings.ToLower(ReadEnv("IS_HTTP", "0")) == "1" @@ -175,6 +185,10 @@ type downloadMediaInput struct { ChatJid string `json:"chat_jid"` } +type getLoginStatusInput struct{} + +type getPairingQrInput struct{} + func callAPI(method, path string, body any) ([]byte, error) { token, err := GetOrRefreshJwtToken() if err != nil { @@ -556,3 +570,55 @@ func sendAudioMessageHandler(ctx context.Context, return &mcp.CallToolResult{IsError: !success}, resultData, nil } + +func getLoginStatusHandler( + ctx context.Context, + req *mcp.CallToolRequest, + _ getLoginStatusInput, +) (*mcp.CallToolResult, any, error) { + data, err := callAPI(http.MethodGet, "/auth/status", nil) + if err != nil { + return ErrResult(fmt.Sprintf("failed to fetch login status: %v", err)), nil, nil + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(data)}}, + }, nil, nil +} + +func getPairingQrHandler( + ctx context.Context, + req *mcp.CallToolRequest, + _ getPairingQrInput, +) (*mcp.CallToolResult, any, error) { + token, err := GetOrRefreshJwtToken() + if err != nil { + return ErrResult(fmt.Sprintf("authentication failed: %v", err)), nil, nil + } + httpReq, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/auth/pairing-qr", apiBaseURL), nil) + if err != nil { + return ErrResult(fmt.Sprintf("failed to build request: %v", err)), nil, nil + } + httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + client := &http.Client{Timeout: apiTimeout} + resp, err := client.Do(httpReq) + if err != nil { + return ErrResult(fmt.Sprintf("request failed: %v", err)), nil, nil + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + switch resp.StatusCode { + case http.StatusOK: + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.ImageContent{Data: body, MIMEType: "image/png"}}, + }, nil, nil + case http.StatusGone: + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "No pairing QR available. The bridge is either already logged in or has not started the pairing flow yet."}}, + }, nil, nil + default: + return ErrResult(fmt.Sprintf("unexpected status %d: %s", resp.StatusCode, string(body))), nil, nil + } +} diff --git a/whatsapp-mcp-server/helpers/tools.go b/whatsapp-mcp-server/helpers/tools.go index 608414b..5c48924 100644 --- a/whatsapp-mcp-server/helpers/tools.go +++ b/whatsapp-mcp-server/helpers/tools.go @@ -2,13 +2,23 @@ package helpers import ( "encoding/json" + "log/slog" "os" "time" "github.com/modelcontextprotocol/go-sdk/mcp" ) -var apiBaseURL = ReadEnv("API_BASE_URL", "http://192.168.178.119:30015/api") +var apiBaseURL = readApiBaseURL() + +func readApiBaseURL() string { + if v := ReadEnv("API_BASE_URL", "http://192.168.178.119:30015/api"); v != "" { + return v + } + const fallback = "http://localhost:8080/api" + slog.Warn("api_base_url not set, using default", "fallback", fallback) + return fallback +} const apiTimeout = 25 * time.Second