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

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

* fix(bridge): validate media path

* update: dependencies

* fix: added LID to contact number migration
This commit is contained in:
Atul Singh
2026-05-13 12:54:31 +02:00
committed by GitHub
parent 680ef32b31
commit 193e0d3c77
20 changed files with 1245 additions and 173 deletions
+15
View File
@@ -0,0 +1,15 @@
# === REQUIRED ===
# Same value as WHATSAPP_API_KEY in the bridge's .env.
# The MCP server presents this to the bridge's /auth/login endpoint
# to obtain a short-lived JWT for /api/* calls.
# (WHATSAPP_API_SECRET is also accepted as a deprecated alias.)
WHATSAPP_API_KEY=
# === OPTIONAL ===
# Bridge API base URL. Default: http://localhost:8080/api.
API_BASE_URL=http://localhost:8080/api
# === HTTP mode (optional) ===
# Set IS_HTTP=true to expose MCP over HTTP instead of stdio.
IS_HTTP=false
HTTP_BASE_URL=0.0.0.0:5777
+30
View File
@@ -0,0 +1,30 @@
# Build the manager binary
FROM golang:1.25.7 AS builder
ARG TARGETARCH
ARG TARGETOS
WORKDIR /project
# Copy the Go Modules manifests
COPY go.mod go.mod
COPY go.sum go.sum
# cache deps before building and copying source so that we don't need to re-download as much
# and so that source changes don't invalidate our downloaded layer
RUN go mod download
# Copy the go source
COPY . .
# Build
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GO111MODULE=on go build -a -o whatsapp-bridge main.go
# Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /project
COPY --from=builder /project/whatsapp-bridge .
USER 65532:65532
ENTRYPOINT ["/project/whatsapp-bridge"]
+15 -2
View File
@@ -14,12 +14,25 @@ import (
)
var (
apiSecret = ReadEnv("WHATSAPP_API_SECRET", "")
apiKey = readApiKeyEnv()
jwtToken string
tokenMutex sync.Mutex
tokenExpiresAt time.Time
)
func readApiKeyEnv() string {
if v := ReadEnv("WHATSAPP_API_KEY", ""); v != "" {
return v
}
if v := ReadEnv("WHATSAPP_API_SECRET", ""); v != "" {
slog.Warn("env var is deprecated, use the new name",
"deprecated", "WHATSAPP_API_SECRET",
"use_instead", "WHATSAPP_API_KEY")
return v
}
return ""
}
// GetOrRefreshJwtToken returns a valid JWT or fetches a new one
func GetOrRefreshJwtToken() (string, error) {
tokenMutex.Lock()
@@ -35,7 +48,7 @@ func GetOrRefreshJwtToken() (string, error) {
if err != nil {
return "", err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiSecret))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
+66
View File
@@ -82,6 +82,16 @@ func InitMcpTool() {
Description: "Download media from a WhatsApp message and return local file path.",
}, downloadMediaHandler)
mcp.AddTool[getLoginStatusInput, any](server, &mcp.Tool{
Name: "get_login_status",
Description: "Check whether the WhatsApp bridge is connected and logged in. Returns {connected, logged_in, pairing_required}.",
}, getLoginStatusHandler)
mcp.AddTool[getPairingQrInput, any](server, &mcp.Tool{
Name: "get_pairing_qr",
Description: "Fetch the WhatsApp pairing QR as a PNG image. Returns image content when pairing is required; returns a text message when the bridge is already logged in or pairing has not started.",
}, getPairingQrHandler)
isHttp := strings.ToLower(ReadEnv("IS_HTTP", "false")) == "true" ||
strings.ToLower(ReadEnv("IS_HTTP", "0")) == "1"
@@ -175,6 +185,10 @@ type downloadMediaInput struct {
ChatJid string `json:"chat_jid"`
}
type getLoginStatusInput struct{}
type getPairingQrInput struct{}
func callAPI(method, path string, body any) ([]byte, error) {
token, err := GetOrRefreshJwtToken()
if err != nil {
@@ -556,3 +570,55 @@ func sendAudioMessageHandler(ctx context.Context,
return &mcp.CallToolResult{IsError: !success}, resultData, nil
}
func getLoginStatusHandler(
ctx context.Context,
req *mcp.CallToolRequest,
_ getLoginStatusInput,
) (*mcp.CallToolResult, any, error) {
data, err := callAPI(http.MethodGet, "/auth/status", nil)
if err != nil {
return ErrResult(fmt.Sprintf("failed to fetch login status: %v", err)), nil, nil
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(data)}},
}, nil, nil
}
func getPairingQrHandler(
ctx context.Context,
req *mcp.CallToolRequest,
_ getPairingQrInput,
) (*mcp.CallToolResult, any, error) {
token, err := GetOrRefreshJwtToken()
if err != nil {
return ErrResult(fmt.Sprintf("authentication failed: %v", err)), nil, nil
}
httpReq, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/auth/pairing-qr", apiBaseURL), nil)
if err != nil {
return ErrResult(fmt.Sprintf("failed to build request: %v", err)), nil, nil
}
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
client := &http.Client{Timeout: apiTimeout}
resp, err := client.Do(httpReq)
if err != nil {
return ErrResult(fmt.Sprintf("request failed: %v", err)), nil, nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
switch resp.StatusCode {
case http.StatusOK:
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.ImageContent{Data: body, MIMEType: "image/png"}},
}, nil, nil
case http.StatusGone:
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: "No pairing QR available. The bridge is either already logged in or has not started the pairing flow yet."}},
}, nil, nil
default:
return ErrResult(fmt.Sprintf("unexpected status %d: %s", resp.StatusCode, string(body))), nil, nil
}
}
+11 -1
View File
@@ -2,13 +2,23 @@ package helpers
import (
"encoding/json"
"log/slog"
"os"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var apiBaseURL = ReadEnv("API_BASE_URL", "http://192.168.178.119:30015/api")
var apiBaseURL = readApiBaseURL()
func readApiBaseURL() string {
if v := ReadEnv("API_BASE_URL", "http://192.168.178.119:30015/api"); v != "" {
return v
}
const fallback = "http://localhost:8080/api"
slog.Warn("api_base_url not set, using default", "fallback", fallback)
return fallback
}
const apiTimeout = 25 * time.Second