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": {
|
"whatsapp-mcp": {
|
||||||
"command": "{{PROJECT_BASE_PATH}}/whatsapp-mcp-server/whatsapp-mcp",
|
"command": "{{PROJECT_BASE_PATH}}/whatsapp-mcp-server/whatsapp-mcp",
|
||||||
"env": {
|
"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`
|
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
|
```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
|
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
|
## Authentication
|
||||||
|
|
||||||
The HTTP API uses a two-step **API key → JWT** flow. The `API_KEY` you set in
|
The HTTP API is protected by a two-step **API key → JWT** flow.
|
||||||
`docker-compose.yaml` is exchanged for a short-lived JWT, which is then used
|
|
||||||
on every subsequent request to `/api/*`.
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
curl -X POST http://localhost:8080/auth/login \
|
curl -X POST \
|
||||||
-H "Authorization: Bearer <your-api-key>"
|
-H "Authorization: Bearer $WHATSAPP_API_KEY" \
|
||||||
# => {"token":"<jwt>"}
|
http://localhost:8080/auth/login
|
||||||
|
# {"token":"eyJhbGciOiJIUzI1NiIs..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Call protected endpoints with the JWT
|
### Step 2: Call the API
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Bearer <jwt>" \
|
TOKEN=eyJhbGciOiJIUzI1NiIs...
|
||||||
http://localhost:8080/api/messages
|
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/chats
|
||||||
```
|
```
|
||||||
|
|
||||||
The MCP server handles this exchange automatically — you only need to set the
|
The MCP server does this automatically: configure `WHATSAPP_API_KEY` and it
|
||||||
`WHATSAPP_API_SECRET` environment variable to match the bridge's `API_KEY`.
|
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
|
## 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_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)
|
- **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
|
- **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
|
### 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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<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">
|
<component name="db-tree-configuration">
|
||||||
<option name="data" value="---------------------------------------- 1:0:4f6200ec-1eae-4549-9d93-2a328888b8d7 2:0:ae157347-e4f8-43bd-a8b5-8556d3489f79 " />
|
<option name="data" value="---------------------------------------- 1:0:4f6200ec-1eae-4549-9d93-2a328888b8d7 2:0:ae157347-e4f8-43bd-a8b5-8556d3489f79 " />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ RUN go mod download
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build
|
# 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
|
# Use distroless as minimal base image to package the manager binary
|
||||||
# Refer to https://github.com/GoogleContainerTools/distroless for more details
|
# Refer to https://github.com/GoogleContainerTools/distroless for more details
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
WORKDIR /project
|
WORKDIR /project
|
||||||
COPY --from=builder /project/whatsapp_mcp_go .
|
COPY --from=builder /project/whatsapp-bridge .
|
||||||
|
|
||||||
USER 65532:65532
|
USER 65532:65532
|
||||||
|
|
||||||
ENTRYPOINT ["/project/whatsapp_mcp_go"]
|
ENTRYPOINT ["/project/whatsapp-bridge"]
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"whatsapp-bridge/config"
|
"whatsapp-bridge/config"
|
||||||
@@ -17,11 +19,31 @@ type Claims struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func LoginHandler(cfg *config.Config) http.HandlerFunc {
|
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) {
|
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) {
|
if subtle.ConstantTimeCompare(expected, got) != 1 {
|
||||||
fmt.Println("Invalid API key")
|
slog.Warn("login rejected: bad api key", "remote", r.RemoteAddr)
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -29,6 +51,8 @@ func LoginHandler(cfg *config.Config) http.HandlerFunc {
|
|||||||
claims := Claims{
|
claims := Claims{
|
||||||
Service: "mcp-server",
|
Service: "mcp-server",
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "whatsapp-bridge",
|
||||||
|
Audience: []string{"whatsapp-mcp-server"},
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(45 * time.Minute)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(45 * time.Minute)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
},
|
},
|
||||||
@@ -37,7 +61,7 @@ func LoginHandler(cfg *config.Config) http.HandlerFunc {
|
|||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
signed, err := token.SignedString(cfg.JWTSecret)
|
signed, err := token.SignedString(cfg.JWTSecret)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,18 +81,25 @@ func JwtAuthMiddleware(cfg *config.Config, next http.Handler) http.Handler {
|
|||||||
|
|
||||||
tokenStr := strings.TrimPrefix(auth, "Bearer ")
|
tokenStr := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(
|
||||||
return cfg.JWTSecret, nil
|
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 {
|
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)
|
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !token.Valid {
|
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)
|
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||||
return
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type dbConfig struct {
|
type dbConfig struct {
|
||||||
@@ -14,41 +16,66 @@ type dbConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DB dbConfig
|
DB dbConfig
|
||||||
JWTSecret []byte
|
JWTSecret []byte
|
||||||
APIKey string
|
APIKey string
|
||||||
WebhookUrl string
|
WebhookUrl string
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
AuthLoginRate string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*Config, error) {
|
func LoadConfig() (*Config, error) {
|
||||||
user, ok := os.LookupEnv("POSTGRES_USER")
|
isPostgres := os.Getenv("IS_POSTGRES") == "true"
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("missing POSTGRES_USER")
|
var user, pass, host, port string
|
||||||
}
|
if isPostgres {
|
||||||
pass, ok := os.LookupEnv("POSTGRES_PASS")
|
var ok bool
|
||||||
if !ok {
|
user, ok = os.LookupEnv("POSTGRES_USER")
|
||||||
return nil, fmt.Errorf("missing POSTGRES_PASS")
|
if !ok {
|
||||||
}
|
return nil, fmt.Errorf("missing POSTGRES_USER")
|
||||||
host, ok := os.LookupEnv("POSTGRES_HOST")
|
}
|
||||||
if !ok {
|
pass, ok = os.LookupEnv("POSTGRES_PASS")
|
||||||
return nil, fmt.Errorf("missing POSTGRES_HOST")
|
if !ok {
|
||||||
}
|
return nil, fmt.Errorf("missing POSTGRES_PASS")
|
||||||
port, ok := os.LookupEnv("POSTGRES_PORT")
|
}
|
||||||
if !ok {
|
host, ok = os.LookupEnv("POSTGRES_HOST")
|
||||||
return nil, fmt.Errorf("missing POSTGRES_PORT")
|
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 {
|
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 {
|
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")
|
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{
|
return &Config{
|
||||||
DB: dbConfig{
|
DB: dbConfig{
|
||||||
@@ -58,8 +85,53 @@ func LoadConfig() (*Config, error) {
|
|||||||
Port: port,
|
Port: port,
|
||||||
IsPostgres: isPostgres,
|
IsPostgres: isPostgres,
|
||||||
},
|
},
|
||||||
JWTSecret: []byte(jwtSecret),
|
JWTSecret: []byte(jwtSecret),
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
WebhookUrl: webhookUrl,
|
WebhookUrl: webhookUrl,
|
||||||
|
Host: serverHost,
|
||||||
|
Port: serverPort,
|
||||||
|
AuthLoginRate: authLoginRate,
|
||||||
}, nil
|
}, 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:
|
services:
|
||||||
wa:
|
postgres:
|
||||||
build:
|
image: postgres:16
|
||||||
context: .
|
container_name: postgres
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: wa
|
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
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"
|
IS_POSTGRES: "true"
|
||||||
POSTGRES_USER: "test"
|
POSTGRES_USER: "${POSTGRES_USER}"
|
||||||
POSTGRES_PASS: "test"
|
POSTGRES_PASS: "${POSTGRES_PASS}"
|
||||||
POSTGRES_HOST: "postgres"
|
POSTGRES_HOST: postgres
|
||||||
POSTGRES_PORT: "5432"
|
POSTGRES_PORT: "5432"
|
||||||
# Replace with two strong, independent random strings before bringing the stack up.
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
# Suggested: openssl rand -base64 64 | tr -d '\n=' | tr '/+' '_-' | head -c 64
|
AUTH_LOGIN_RATE: "${AUTH_LOGIN_RATE:-}"
|
||||||
API_KEY: "CHANGE_ME_TO_A_LONG_RANDOM_STRING_AT_LEAST_64_CHARS_______________"
|
WEBHOOK_URL: "${WEBHOOK_URL:-}"
|
||||||
JWT_SECRET: "CHANGE_ME_TO_A_DIFFERENT_LONG_RANDOM_STRING_AT_LEAST_64_CHARS____"
|
HOST: "${HOST:-}"
|
||||||
# Optional: set this to forward every inbound message to a webhook (e.g. n8n).
|
PORT: "${PORT:-8080}"
|
||||||
# WEBHOOK_URL: "http://your-host:5678/webhook/whatsapp"
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- media:/project/store
|
- bridge-store:/project/store
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
deploy:
|
deploy:
|
||||||
@@ -31,19 +45,32 @@ services:
|
|||||||
reservations:
|
reservations:
|
||||||
cpus: "0.5"
|
cpus: "0.5"
|
||||||
memory: "64M"
|
memory: "64M"
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
wa-mcp:
|
||||||
container_name: postgres
|
build:
|
||||||
|
context: ./whatsapp-mcp-server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: wa-mcp
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: "test"
|
WHATSAPP_API_KEY: "${WHATSAPP_API_KEY:?set WHATSAPP_API_KEY in environment or .env}"
|
||||||
POSTGRES_PASSWORD: "test"
|
API_BASE_URL: "http://wa-bridge:8080/api"
|
||||||
POSTGRES_DB: "whatsapp"
|
IS_HTTP: "true"
|
||||||
|
HTTP_BASE_URL: "0.0.0.0:5777"
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5777:5777"
|
||||||
volumes:
|
depends_on:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- wa-bridge
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "0.5"
|
||||||
|
memory: "64M"
|
||||||
|
reservations:
|
||||||
|
cpus: "0.25"
|
||||||
|
memory: "32M"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
media:
|
|
||||||
pgdata:
|
pgdata:
|
||||||
|
bridge-store:
|
||||||
+16
-13
@@ -4,10 +4,12 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/lib/pq v1.11.2
|
github.com/lib/pq v1.12.3
|
||||||
github.com/mattn/go-sqlite3 v1.14.34
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
github.com/mdp/qrterminal v1.0.1
|
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
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,16 +20,17 @@ require (
|
|||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||||
github.com/rs/zerolog v1.34.0 // indirect
|
github.com/rs/zerolog v1.35.1 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.33 // indirect
|
||||||
go.mau.fi/libsignal v0.2.1 // indirect
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
go.mau.fi/util v0.9.6 // indirect
|
go.mau.fi/util v0.9.8 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.51.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/text v0.34.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
|
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/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 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
||||||
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
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.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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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.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 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.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 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.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 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c=
|
||||||
github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ=
|
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 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
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 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
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 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
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 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
|
||||||
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
|
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/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8=
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
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 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-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 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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 h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
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/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
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 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,10 +19,13 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
"whatsapp-bridge/auth"
|
"whatsapp-bridge/auth"
|
||||||
"whatsapp-bridge/config"
|
"whatsapp-bridge/config"
|
||||||
|
bridgelogger "whatsapp-bridge/logger"
|
||||||
|
"whatsapp-bridge/wastate"
|
||||||
|
|
||||||
"go.mau.fi/whatsmeow/proto/waCompanionReg"
|
"go.mau.fi/whatsmeow/proto/waCompanionReg"
|
||||||
"go.mau.fi/whatsmeow/socket"
|
"go.mau.fi/whatsmeow/socket"
|
||||||
@@ -29,6 +33,7 @@ import (
|
|||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"github.com/mdp/qrterminal"
|
"github.com/mdp/qrterminal"
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
||||||
@@ -123,6 +128,44 @@ func openDatabase(dbName string) (*sql.DB, error) {
|
|||||||
return sql.Open("sqlite3", "file:store/messages.db?_foreign_keys=on")
|
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
|
// NewMessageStore Initialize message store
|
||||||
func NewMessageStore() (*MessageStore, error) {
|
func NewMessageStore() (*MessageStore, error) {
|
||||||
if err := os.MkdirAll("store", 0755); err != nil {
|
if err := os.MkdirAll("store", 0755); err != nil {
|
||||||
@@ -181,6 +224,231 @@ func (store *MessageStore) Close() error {
|
|||||||
return store.db.Close()
|
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
|
// StoreChat Store a chat in the database
|
||||||
func (store *MessageStore) StoreChat(jid, name string, lastMessageTime time.Time) error {
|
func (store *MessageStore) StoreChat(jid, name string, lastMessageTime time.Time) error {
|
||||||
if isPostgres {
|
if isPostgres {
|
||||||
@@ -394,7 +662,11 @@ func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message str
|
|||||||
msg := &waE2E.Message{}
|
msg := &waE2E.Message{}
|
||||||
|
|
||||||
if mediaPath != "" {
|
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 {
|
if err != nil {
|
||||||
return false, fmt.Sprintf("Error reading media file: %v", err)
|
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)
|
return false, fmt.Sprintf("Error uploading media: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Media uploaded", resp)
|
slog.Info("media uploaded", "response", resp)
|
||||||
|
|
||||||
switch mediaType {
|
switch mediaType {
|
||||||
case whatsmeow.MediaImage:
|
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)
|
return false, fmt.Sprintf("Failed to analyze Ogg Opus file: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} 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{
|
msg.AudioMessage = &waE2E.AudioMessage{
|
||||||
@@ -603,8 +875,8 @@ func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *ev
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
}()
|
}()
|
||||||
chatJID := msg.Info.Chat.String()
|
chatJID := normalizeUserJID(client, msg.Info.Chat).String()
|
||||||
sender := msg.Info.Sender.User
|
sender := normalizeUserJID(client, msg.Info.Sender).User
|
||||||
|
|
||||||
name := GetChatName(client, messageStore, msg.Info.Chat, chatJID, nil, sender, logger)
|
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 != "" {
|
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 != "" {
|
} 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")
|
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)
|
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)
|
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
|
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
|
// 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()
|
apiMux := http.NewServeMux()
|
||||||
|
|
||||||
// Send message
|
// Send message
|
||||||
@@ -898,10 +1170,10 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port
|
|||||||
return
|
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)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
if !success {
|
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
|
// Authentication
|
||||||
protected := auth.JwtAuthMiddleware(cfg, apiMux)
|
protected := auth.JwtAuthMiddleware(cfg, apiMux)
|
||||||
http.Handle("/api/", http.StripPrefix("/api", protected))
|
http.Handle("/api/", http.StripPrefix("/api", protected))
|
||||||
http.Handle("/auth/login", auth.LoginHandler(cfg))
|
http.Handle("/auth/login", auth.LoginHandler(cfg))
|
||||||
|
|
||||||
serverAddr := fmt.Sprintf(":%d", port)
|
serverAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
fmt.Printf("Starting REST API server on %s...\n", serverAddr)
|
slog.Info("starting REST API server", "addr", serverAddr)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := http.ListenAndServe(serverAddr, nil); err != nil {
|
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
|
// Handle history sync events
|
||||||
func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, historySync *events.HistorySync, logger waLog.Logger) {
|
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
|
syncedCount := 0
|
||||||
for _, conversation := range historySync.Data.Conversations {
|
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)
|
logger.Warnf("Failed to parse JID %s: %v", chatJID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
jid = normalizeUserJID(client, jid)
|
||||||
|
chatJID = jid.String()
|
||||||
|
|
||||||
name := GetChatName(client, messageStore, jid, chatJID, conversation, "", logger)
|
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
|
isFromMe = *msg.Message.Key.FromMe
|
||||||
}
|
}
|
||||||
if !isFromMe && msg.Message.Key.Participant != nil && *msg.Message.Key.Participant != "" {
|
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 {
|
} else if isFromMe {
|
||||||
sender = client.Store.ID.User
|
sender = client.Store.ID.User
|
||||||
} else {
|
} 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
|
// Request history sync from the server
|
||||||
func requestHistorySync(client *whatsmeow.Client) {
|
func requestHistorySync(client *whatsmeow.Client) {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
fmt.Println("Client is not initialized. Cannot request history sync.")
|
slog.Error("client is not initialized, cannot request history sync")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !client.IsConnected() {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if client.Store.ID == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
historyMsg := client.BuildHistorySyncRequest(nil, 100)
|
historyMsg := client.BuildHistorySyncRequest(nil, 100)
|
||||||
if historyMsg == nil {
|
if historyMsg == nil {
|
||||||
fmt.Println("Failed to build history sync request.")
|
slog.Error("failed to build history sync request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,9 +1769,9 @@ func requestHistorySync(client *whatsmeow.Client) {
|
|||||||
}, historyMsg)
|
}, historyMsg)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Failed to request history sync: %v\n", err)
|
slog.Error("failed to request history sync", "err", err)
|
||||||
} else {
|
} 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])
|
preSkip = binary.LittleEndian.Uint16(pageData[headPos+10 : headPos+12])
|
||||||
sampleRate = binary.LittleEndian.Uint32(pageData[headPos+12 : headPos+16])
|
sampleRate = binary.LittleEndian.Uint32(pageData[headPos+12 : headPos+16])
|
||||||
foundOpusHead = true
|
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 {
|
if !foundOpusHead {
|
||||||
fmt.Println("Warning: OpusHead not found, using default values")
|
slog.Warn("opushead not found, using default values")
|
||||||
}
|
}
|
||||||
|
|
||||||
if lastGranule > 0 {
|
if lastGranule > 0 {
|
||||||
durationSeconds := float64(lastGranule-uint64(preSkip)) / float64(sampleRate)
|
durationSeconds := float64(lastGranule-uint64(preSkip)) / float64(sampleRate)
|
||||||
duration = uint32(math.Ceil(durationSeconds))
|
duration = uint32(math.Ceil(durationSeconds))
|
||||||
fmt.Printf("Calculated Opus duration from granule: %f seconds (lastGranule=%d)\n",
|
slog.Info("calculated Opus duration from granule", "duration_seconds", durationSeconds, "last_granule", lastGranule)
|
||||||
durationSeconds, lastGranule)
|
|
||||||
} else {
|
} 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
|
durationEstimate := float64(len(data)) / 2000.0
|
||||||
duration = uint32(durationEstimate)
|
duration = uint32(durationEstimate)
|
||||||
}
|
}
|
||||||
@@ -1550,8 +1856,7 @@ func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) {
|
|||||||
|
|
||||||
waveform = placeholderWaveform(duration)
|
waveform = placeholderWaveform(duration)
|
||||||
|
|
||||||
fmt.Printf("Ogg Opus analysis: size=%d bytes, calculated duration=%d sec, waveform=%d bytes\n",
|
slog.Info("ogg opus analysis complete", "size_bytes", len(data), "duration_sec", duration, "waveform_bytes", len(waveform))
|
||||||
len(data), duration, len(waveform))
|
|
||||||
|
|
||||||
return duration, waveform, nil
|
return duration, waveform, nil
|
||||||
}
|
}
|
||||||
@@ -2381,8 +2686,11 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.SetDefault(bridgelogger.New(os.Getenv("LOG_LEVEL")))
|
||||||
|
|
||||||
cfg, err := config.LoadConfig()
|
cfg, err := config.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to load config: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2412,11 +2720,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state := wastate.New()
|
||||||
|
|
||||||
version, err := CustomGetLatestVersion(context.Background(), nil)
|
version, err := CustomGetLatestVersion(context.Background(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to retrieve current WhatsApp Web client Version")
|
logger.Errorf("Failed to retrieve current WhatsApp Web client Version")
|
||||||
} else {
|
} else {
|
||||||
store.SetWAVersion(*version)
|
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])
|
logger.Infof("WhatsApp Web Client Version: %d.%d.%d\n", version[0], version[1], version[2])
|
||||||
}
|
}
|
||||||
client := whatsmeow.NewClient(deviceStore, logger)
|
client := whatsmeow.NewClient(deviceStore, logger)
|
||||||
@@ -2435,6 +2746,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer messageStore.Close()
|
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{}) {
|
client.AddEventHandler(func(evt interface{}) {
|
||||||
switch v := evt.(type) {
|
switch v := evt.(type) {
|
||||||
case *events.Message:
|
case *events.Message:
|
||||||
@@ -2445,58 +2762,112 @@ func main() {
|
|||||||
|
|
||||||
case *events.Connected:
|
case *events.Connected:
|
||||||
logger.Infof("Connected to WhatsApp")
|
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:
|
case *events.LoggedOut:
|
||||||
logger.Warnf("Device logged out, please scan QR code to log in again")
|
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 {
|
// REST server comes up first so /api/auth/status and /api/auth/pairing-qr
|
||||||
qrChan, _ := client.GetQRChannel(context.Background())
|
// are reachable during pairing. WhatsApp connect runs concurrently below.
|
||||||
err = client.Connect()
|
startRESTServer(client, messageStore, cfg, state)
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("Failed to connect: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for evt := range qrChan {
|
// Periodically refresh the WhatsApp Web client version so reconnects
|
||||||
if evt.Event == "code" {
|
// after transient drops use a current version string. Only the next
|
||||||
fmt.Println("\nScan this QR code with your WhatsApp app:")
|
// connection picks up the refreshed value; the active session is unaffected.
|
||||||
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
|
go func() {
|
||||||
} else if evt.Event == "success" {
|
t := time.NewTicker(6 * time.Hour)
|
||||||
connected <- true
|
defer t.Stop()
|
||||||
break
|
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 {
|
// Pair / connect to WhatsApp in a goroutine so main can block on signals.
|
||||||
case <-connected:
|
go func() {
|
||||||
fmt.Println("\nSuccessfully connected and authenticated!")
|
if client.Store.ID == nil {
|
||||||
case <-time.After(3 * time.Minute):
|
qrChan, _ := client.GetQRChannel(context.Background())
|
||||||
logger.Errorf("Timeout waiting for QR code scan")
|
if err := client.Connect(); err != nil {
|
||||||
return
|
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)
|
exitChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(exitChan, syscall.SIGINT, syscall.SIGTERM)
|
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 (
|
var (
|
||||||
apiSecret = ReadEnv("WHATSAPP_API_SECRET", "")
|
apiKey = readApiKeyEnv()
|
||||||
jwtToken string
|
jwtToken string
|
||||||
tokenMutex sync.Mutex
|
tokenMutex sync.Mutex
|
||||||
tokenExpiresAt time.Time
|
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
|
// GetOrRefreshJwtToken returns a valid JWT or fetches a new one
|
||||||
func GetOrRefreshJwtToken() (string, error) {
|
func GetOrRefreshJwtToken() (string, error) {
|
||||||
tokenMutex.Lock()
|
tokenMutex.Lock()
|
||||||
@@ -35,7 +48,7 @@ func GetOrRefreshJwtToken() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ func InitMcpTool() {
|
|||||||
Description: "Download media from a WhatsApp message and return local file path.",
|
Description: "Download media from a WhatsApp message and return local file path.",
|
||||||
}, downloadMediaHandler)
|
}, 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" ||
|
isHttp := strings.ToLower(ReadEnv("IS_HTTP", "false")) == "true" ||
|
||||||
strings.ToLower(ReadEnv("IS_HTTP", "0")) == "1"
|
strings.ToLower(ReadEnv("IS_HTTP", "0")) == "1"
|
||||||
|
|
||||||
@@ -175,6 +185,10 @@ type downloadMediaInput struct {
|
|||||||
ChatJid string `json:"chat_jid"`
|
ChatJid string `json:"chat_jid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type getLoginStatusInput struct{}
|
||||||
|
|
||||||
|
type getPairingQrInput struct{}
|
||||||
|
|
||||||
func callAPI(method, path string, body any) ([]byte, error) {
|
func callAPI(method, path string, body any) ([]byte, error) {
|
||||||
token, err := GetOrRefreshJwtToken()
|
token, err := GetOrRefreshJwtToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -556,3 +570,55 @@ func sendAudioMessageHandler(ctx context.Context,
|
|||||||
|
|
||||||
return &mcp.CallToolResult{IsError: !success}, resultData, nil
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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
|
const apiTimeout = 25 * time.Second
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user