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