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:
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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="---------------------------------------- 1:0:4f6200ec-1eae-4549-9d93-2a328888b8d7 2:0:ae157347-e4f8-43bd-a8b5-8556d3489f79 " />
|
||||
</component>
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user