feat: ratelimit, ghcr package support, media path sensitization, LID to CN migration (#6)

* feat: ratelimit, ghcr package and proper project refactor from askarzh

* fix(bridge): validate media path

* update: dependencies

* fix: added LID to contact number migration
This commit is contained in:
Atul Singh
2026-05-13 12:54:31 +02:00
committed by GitHub
parent 680ef32b31
commit 193e0d3c77
20 changed files with 1245 additions and 173 deletions
+71
View File
@@ -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
+34
View File
@@ -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
+57 -15
View File
@@ -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 <<EOF
WHATSAPP_API_KEY=$(openssl rand -base64 48)
WHATSAPP_JWT_SECRET=$(openssl rand -base64 48)
POSTGRES_USER=whatsapp
POSTGRES_PASS=$(openssl rand -base64 24)
EOF
# 2. Bring it all up
docker compose up
```
Once running:
- Bridge REST API: `http://localhost:8080/api/...` (after `/auth/login`)
- MCP HTTP endpoint: `http://localhost:5777`
### MCP server: stdio vs HTTP
The MCP server has two run modes selected by `IS_HTTP`:
| Mode | When to use | How to launch |
| --- | --- | --- |
| **stdio** (`IS_HTTP=false`, default) | Claude Desktop, Cursor, or any host that spawns the MCP server as a child process | `go build` a local binary; reference it from `claude_desktop_config.json` / `mcp.json` (see below) |
| **HTTP** (`IS_HTTP=true`) | n8n, web automation, or any client connecting over the network | `docker compose up` — the `wa-mcp` service runs in HTTP mode by default, listening on `:5777` |
Stdio mode is **not** appropriate for the Docker image — Claude Desktop
does not natively `docker run` to spawn an MCP child. Build a local
binary instead.
## Authentication
The HTTP API uses a two-step **API key → JWT** flow. The `API_KEY` you set in
`docker-compose.yaml` is exchanged for a short-lived JWT, which is then used
on every subsequent request to `/api/*`.
The HTTP API is protected by a two-step **API key → JWT** flow.
### 1. Exchange the API key for a JWT
1. Send your static API key to `/auth/login` once. The bridge returns a 45-minute JWT.
2. Use the JWT as `Authorization: Bearer <jwt>` on every `/api/...` call.
### Step 1: Get a JWT
```bash
curl -X POST http://localhost:8080/auth/login \
-H "Authorization: Bearer <your-api-key>"
# => {"token":"<jwt>"}
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 <jwt>" \
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=<count>/<window>` (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
+28
View File
@@ -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: <count>/<window>. Default: 5/1m.
AUTH_LOGIN_RATE=
# Logger level: debug | info | warn | error. Default: info.
LOG_LEVEL=info
+7
View File
@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:4f6200ec-1eae-4549-9d93-2a328888b8d7
2:0:ae157347-e4f8-43bd-a8b5-8556d3489f79
.</data>
</component>
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:4f6200ec-1eae-4549-9d93-2a328888b8d7&#10;2:0:ae157347-e4f8-43bd-a8b5-8556d3489f79&#10;" />
</component>
+3 -3
View File
@@ -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"]
ENTRYPOINT ["/project/whatsapp-bridge"]
+41 -10
View File
@@ -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
}
+114
View File
@@ -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 <count>/<window>", 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
}
+99 -27
View File
@@ -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
}
+52 -25
View File
@@ -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:
+16 -13
View File
@@ -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
)
+32 -6
View File
@@ -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=
+24
View File
@@ -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}))
}
+442 -71
View File
@@ -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)
+88
View File
@@ -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
}
+15
View File
@@ -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
+30
View File
@@ -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"]
+15 -2
View File
@@ -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)
+66
View File
@@ -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
}
}
+11 -1
View File
@@ -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