diff --git a/.gitignore b/.gitignore index 467cd39..0d7d33c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ *.db-shm *.db-wal .env +web/tsconfig.tsbuildinfo diff --git a/README.md b/README.md index 065cd69..c14ffeb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Version](https://img.shields.io/badge/version-0.1.0-blueviolet)](package.json) **A local-first LLM runtime for developers who demand more.** -Persistent sessions · Multi-provider · Branching · Diffing · Sandboxing · Evals · Pipelines · MCP +Persistent sessions · Multi-provider · Branching · Diffing · Sandboxing · Evals · Pipelines · MCP · **WhatsApp** @@ -65,6 +65,9 @@ Export conversation datasets in JSONL format and submit fine-tuning jobs directl +### 📱 WhatsApp Business Integration +Send and receive WhatsApp messages via the Meta Business Cloud API. Configure webhooks, test sends, view a live message log, and optionally enable auto-replies driven by any JCLAW session. + ### 📡 Automation-Native Output Piping Pipe LLM responses to **files**, the **system clipboard**, **webhooks**, or arbitrary **shell scripts** — first-class primitives, not afterthoughts. @@ -80,6 +83,9 @@ Instantly search across your entire message history using SQLite FTS5 — blazin ### 🔌 Model Context Protocol (MCP) Expose JCLAW as an MCP tool provider for other AI agents. Also acts as an MCP client — connect to any external MCP server and use its tools inside your chat sessions. +### 📈 Live Processing Dashboard +The Overview page shows a real-time processing bar (tokens/sec, last model, last provider) and a Framework Status panel with live indicators for every active framework — Sandbox, Red Team, MCP, WhatsApp, Pipeline, Evals, Fine-Tune, and Embeddings. + @@ -90,13 +96,15 @@ Expose JCLAW as an MCP tool provider for other AI agents. Also acts as an MCP cl ``` ┌─────────────────────────────────────────────────────────────────────────┐ -│ CLI (commander) Web Dashboard (React + Vite + TailwindCSS) │ +│ CLI (commander) Web Dashboard (React + Vite) │ │ │ │ │ │ └──────────── WebSocket ───────┘ │ │ │ │ │ gate/server.ts (Express + WebSocketServer) │ +│ ├─ /webhook/whatsapp GET (verify) + POST (receive) │ +│ └─ /health │ │ │ │ -│ gate/protocol.ts (JSON-RPC method router, ~30 methods) │ +│ gate/protocol.ts (JSON-RPC method router, 40+ methods) │ │ │ │ │ ┌──────────────────────┼────────────────────────────────────────┐ │ │ │ │ │ │ @@ -111,12 +119,12 @@ Expose JCLAW as an MCP tool provider for other AI agents. Also acts as an MCP cl │ └─ metrics*.ts │ │ │ │ agent/runtime.ts ← agentic loop mcp/ ← server + client │ -│ channels/ ← I/O plugin channels │ +│ channels/ ← I/O plugin channels (WhatsApp, …) │ │ plugins/ ← plugin registry │ └─────────────────────────────────────────────────────────────────────────┘ ``` -**Stack:** Node.js 20 (ESM) · TypeScript 5.6 · Express · WebSocket · SQLite (`better-sqlite3`, WAL + FTS5) · React + Vite · TailwindCSS · Vitest +**Stack:** Node.js 20 (ESM) · TypeScript 5.6 · Express · WebSocket · SQLite (`better-sqlite3`, WAL + FTS5) · React + Vite · Vitest --- @@ -197,15 +205,65 @@ jclaw search "context window optimization" | Page | Description | |---|---| -| **Overview** | Session stats, live provider health pings, and cost metrics | +| **Overview** | Live processing bar, framework status panel, session stats, provider health | | **Sessions** | Sortable list of all sessions with token/cost info | | **Session Detail** | Full message thread with inline model tags | | **Prompts** | Saved system prompts with `{{variable}}` templating | | **Templates** | Pre-configured session templates (model, system prompt, cost ceiling) | +| **Chat** | Live streaming chat with tool-call trace | +| **Terminal** | Raw JSON-RPC console | +| **Activity** | Real-time WebSocket frame monitor with tokens/sec counter | +| **Metrics** | Historical token/latency/cost graphs | | **Providers** | API key management, base URL config, connection testing, model listing | -| **Search** | Full-text search across all message history (FTS5) | -| **MCP** | Add/edit/delete MCP server configs, connection status, available tools | | **Sandbox** | Prompt-injection protection, prefix/suffix injection, red-team harness | +| **MCP** | Add/edit/delete MCP server configs, connection status, available tools | +| **WhatsApp** | Meta Business Cloud API channel — config, live message log, test send | +| **Datasets** | Conversation dataset management for fine-tuning | +| **Fine-Tune** | Submit and monitor fine-tuning jobs (OpenAI/Groq) | +| **Evals** | Create eval suites, run benchmarks, view scored results | +| **Embed Search** | Semantic search over message history | +| **Search** | Full-text search (FTS5) across all message history | + +--- + +## 📱 WhatsApp Integration + +JCLAW integrates with the **Meta WhatsApp Business Cloud API** to send and receive WhatsApp messages directly from the dashboard. + +### Setup (5 steps) + +1. **Create a Meta app** at [developers.facebook.com](https://developers.facebook.com/apps/) and add the *WhatsApp Business* product. + +2. **Copy your credentials** from *WhatsApp → API Setup*: + - **Phone Number ID** — a numeric ID tied to your test/production number + - **System User Access Token** — long-lived token from Meta Business Manager + - **App Secret** — found in *App Dashboard → Basic* (used for webhook signature verification) + +3. **Configure JCLAW** — open the **WhatsApp** dashboard page and enter: + - Phone Number ID + - Access Token + - Verify Token (any string you choose, e.g. `jclaw-verify`) + - App Secret *(optional but recommended — enables `X-Hub-Signature-256` validation on inbound webhooks)* + +4. **Register the webhook** in your Meta app under *WhatsApp → Configuration*: + - **Webhook URL:** `https:///webhook/whatsapp` + - **Verify Token:** same string you entered above + - Subscribe to the `messages` field + +5. **Test** — use the *Test Send* panel to send a message to any number approved in your Meta app. + +> **Note:** Inbound messages are broadcast as `whatsapp.message` WebSocket events and appear in the live log instantly. Enable **Auto-Reply** in the config panel to have JCLAW forward incoming messages to a session and reply automatically. + +### Protocol methods + +| Method | Description | +|---|---| +| `whatsapp.config.get` | Get current config (access token, verify token, and app secret are masked) | +| `whatsapp.config.set` | Update Phone Number ID, access token, verify token, app secret, auto-reply settings | +| `whatsapp.send` | Send a text message to a phone number | +| `whatsapp.messages.list` | List recent inbound/outbound messages | + +Inbound messages are also emitted as `whatsapp.message` WebSocket events for real-time display. --- @@ -233,14 +291,25 @@ jclaw-framework/ ├── src/ │ ├── agent/ # Agentic workflow runtime │ ├── channels/ # I/O plugin channels +│ │ └── plugins/ +│ │ ├── exampleChannel.ts # Stdout logger (template) +│ │ └── whatsapp.ts # Meta WhatsApp Business Cloud API │ ├── cli/ # CLI entry point (commander) │ ├── gate/ # Express + WebSocket server & protocol router +│ │ ├── server.ts # HTTP + WS server, WhatsApp webhook endpoints +│ │ ├── protocol.ts # JSON-RPC method router (40+ methods) +│ │ └── whatsapp-store.ts # In-process WhatsApp message store │ ├── mcp/ # MCP server, client manager, shared types │ ├── plugins/ # Plugin registry │ ├── providers/ # LLM adapters (Anthropic, OpenAI, Ollama, …) │ ├── runtime/ # Chat, eval, fine-tune, embeddings, diffing, piping │ └── storage/ # SQLite layer (sessions, messages, prompts, sandbox, …) ├── web/ # React + Vite dashboard frontend +│ └── src/ +│ └── pages/ +│ ├── Overview.tsx # Live processing bar + framework status panel +│ ├── WhatsApp.tsx # WhatsApp channel management page +│ └── ... # All other dashboard pages ├── scripts/ # Utility scripts ├── tsconfig.json └── package.json @@ -273,3 +342,4 @@ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE Built with ☕ and TypeScript · [Report a bug](https://github.com/Jacobcdsmith/jclaw-framework/issues) · [Request a feature](https://github.com/Jacobcdsmith/jclaw-framework/issues) + diff --git a/replit.md b/replit.md index daad6ce..6dbc048 100644 --- a/replit.md +++ b/replit.md @@ -13,19 +13,24 @@ A local-first LLM runtime that treats LLM APIs as a persistent, automated runtim ## Project Structure - `src/gate/` - Core WebSocket + Express gate server (serves the dashboard at `/`) + - `server.ts` - HTTP + WS server, WhatsApp webhook routes (`/webhook/whatsapp`) + - `protocol.ts` - JSON-RPC method router (40+ methods including `whatsapp.*`) + - `whatsapp-store.ts` - In-process message store for the WhatsApp channel - `src/cli/` - CLI entry point using `commander` - `src/storage/` - SQLite persistence layer (sessions, messages, prompts, templates, config, sandbox enforcement) - `src/providers/` - LLM provider adapters (Anthropic, OpenAI-compatible, Ollama, LM Studio) - `src/runtime/` - Core business logic (chat, context management, output piping) -- `src/mcp/` - MCP support: server (jclaw as MCP server), client-manager (connects to external MCP servers), shared types -- `src/channels/` - Input/output plugin channels +- `src/mcp/` - MCP support: server (jclaw as MCP server), client-manager, shared types +- `src/channels/` - I/O plugin channels + - `plugins/whatsapp.ts` - Meta WhatsApp Business Cloud API outbound adapter + - `plugins/exampleChannel.ts` - Stdout logger (channel plugin template) - `src/agent/` - Agentic workflow runtime - `src/plugins/` - Plugin registry - `dist/` - Compiled gate server output (from `npm run build`) - `web/` - React + Vite dashboard frontend - - `web/src/` - React components (Overview, Sessions, SessionDetail, Prompts, Templates, Providers, Search) + - `web/src/pages/Overview.tsx` - Live processing bar + framework status panel + - `web/src/pages/WhatsApp.tsx` - WhatsApp channel management page - `web/src/ws.ts` - WebSocket JSON-RPC client - - `web/dist/` - Built dashboard (served by Express at `/`) ## Running @@ -41,25 +46,47 @@ The "Start application" workflow: ## Dashboard -The web dashboard is a React SPA with a **Star Trek / LCARS terminal** aesthetic (deep black, amber + cyan accents, monospace font, CRT scanlines). Served from the same Express server as the gate. +The web dashboard is a React SPA with a **Star Trek / LCARS terminal** aesthetic (deep black, amber + cyan accents, monospace font). Served from the same Express server as the gate. Pages: -- **Overview** - Session stats and live provider health pings +- **Overview** - Live processing bar (tokens/sec, last model, last provider), framework status panel with live indicators for Sandbox/Red Team/MCP/WhatsApp/Pipeline/Evals/Fine-Tune/Embeddings, session stats, provider health pings - **Sessions** - Sortable list of all sessions with token/cost info - **Session Detail** - Full message thread for a session - **Prompts** - Saved system prompts (expandable cards) -- **Templates** - Session configuration templates (expandable cards) +- **Templates** - Session configuration templates - **Providers** - Per-provider config: API key entry (masked), base URL, Test Connection, available models list - **MCP** - MCP server management: add/edit/delete/enable server configs, view connection status, list available tools -- **Sandbox** - Prompt sandbox: master enable/disable toggle, system prompt prefix/suffix injection (server-level), client override blocking, injection pattern protection, custom blocked phrases +- **Sandbox** - Prompt sandbox: master enable/disable toggle, system prompt prefix/suffix injection, client override blocking, injection pattern protection +- **WhatsApp** - Meta WhatsApp Business Cloud API channel: configuration form (Phone Number ID, access token, verify token, auto-reply), webhook URL display, live message log, test send panel +- **Activity** - Real-time WebSocket frame monitor with tokens/sec counter +- **Metrics** - Historical token/latency/cost graphs - **Search** - Full-text search across all session messages (FTS5) -The frontend communicates with the gate using the existing JSON-RPC WebSocket protocol. +## WhatsApp Integration + +JCLAW integrates with the Meta WhatsApp Business Cloud API: + +**Setup:** +1. Create a Meta app at developers.facebook.com and add the WhatsApp Business product +2. Copy the **Phone Number ID** and **System User Access Token** from WhatsApp → API Setup +3. Open the **WhatsApp** dashboard page and enter the credentials plus a Verify Token +4. Register the webhook in Meta app settings: + - URL: `https:///webhook/whatsapp` + - Verify Token: same string entered in the dashboard + - Subscribe to the `messages` field +5. Use the **Test Send** panel to verify the setup + +**Protocol methods added:** +- `whatsapp.config.get` / `whatsapp.config.set` — read/write config (access token is masked on read) +- `whatsapp.send` — send a text message to a phone number +- `whatsapp.messages.list` — list recent inbound/outbound messages + +Inbound messages are also broadcast as `whatsapp.message` WebSocket events for real-time display. ## Provider Config Provider API keys can be configured via: -1. **The Providers dashboard page** — type and save via `config.set` RPC method (persists to `~/.jclaw/config.json`, re-initializes provider in-memory instantly) +1. **The Providers dashboard page** — type and save via `config.set` RPC method (persists to `~/.jclaw/config.json`) 2. **Environment variables** — `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` (fallback if not in config file) Config file: `~/.jclaw/config.json` @@ -76,4 +103,4 @@ Config file: `~/.jclaw/config.json` ## Port Server runs on port 5000 (configurable via `JCLAW_PORT` environment variable). -Default changed from 18789 → 5000 to match Replit's webview port requirement. + diff --git a/src/channels/plugins/whatsapp.ts b/src/channels/plugins/whatsapp.ts new file mode 100644 index 0000000..c908d4c --- /dev/null +++ b/src/channels/plugins/whatsapp.ts @@ -0,0 +1,71 @@ +import type { JclawChannelPlugin, OutboundContext, DeliveryResult } from "../types.js"; + +const WHATSAPP_API_VERSION = "v20.0"; +const WHATSAPP_BASE = `https://graph.facebook.com/${WHATSAPP_API_VERSION}`; + +// Node 18+ ships global fetch. Guard against environments where it may be missing. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _globalFetch = (globalThis as any).fetch as + | ((url: string, init?: Record) => Promise<{ ok: boolean; status: number; statusText: string; json: () => Promise }>) + | undefined; + +export interface WhatsAppSendOptions { + phoneNumberId: string; + accessToken: string; +} + +export async function sendWhatsAppText( + to: string, + text: string, + opts: WhatsAppSendOptions +): Promise { + if (!_globalFetch) { + return { ok: false, error: "fetch is not available in this runtime (Node 18+ required)" }; + } + const url = `${WHATSAPP_BASE}/${opts.phoneNumberId}/messages`; + const body = { + messaging_product: "whatsapp", + recipient_type: "individual", + to, + type: "text", + text: { preview_url: false, body: text } + }; + + const resp = await _globalFetch(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${opts.accessToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }); + + const raw = await resp.json().catch(() => ({})); + + if (!resp.ok) { + const errMsg = (raw as { error?: { message?: string } }).error?.message ?? `HTTP ${resp.status}`; + return { ok: false, raw, error: errMsg }; + } + + return { ok: true, raw }; +} + +export function createWhatsAppPlugin(opts: WhatsAppSendOptions): JclawChannelPlugin { + return { + id: "whatsapp", + meta: { + id: "whatsapp", + name: "WhatsApp Business", + description: "Meta WhatsApp Business Cloud API outbound adapter" + }, + capabilities: { text: true, media: false }, + outbound: { + async sendText(ctx: OutboundContext): Promise { + if (!opts.phoneNumberId || !opts.accessToken) { + return { ok: false, error: "WhatsApp not configured (missing phoneNumberId or accessToken)" }; + } + return sendWhatsAppText(ctx.to, ctx.text ?? "", opts); + } + } + }; +} diff --git a/src/channels/types.ts b/src/channels/types.ts index 4a67541..cc88ab7 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -17,6 +17,7 @@ export interface OutboundContext { export interface DeliveryResult { ok: boolean; raw?: unknown; + error?: string; } export interface ChannelOutboundAdapter { diff --git a/src/gate/protocol.ts b/src/gate/protocol.ts index f726c42..b7c42c2 100644 --- a/src/gate/protocol.ts +++ b/src/gate/protocol.ts @@ -36,12 +36,14 @@ import { getTemplateByName, deleteTemplate } from "../storage/templates.js"; -import { readConfig, writeConfig, mergeWithEnv, maskKey, DEFAULT_SANDBOX, DEFAULT_REDTEAM } from "../storage/config.js"; +import { readConfig, writeConfig, mergeWithEnv, maskKey, DEFAULT_SANDBOX, DEFAULT_REDTEAM, DEFAULT_WHATSAPP } from "../storage/config.js"; import { recordMetric, listMetrics, getStabilitySummary } from "../storage/metrics.js"; import { createAnthropicProvider } from "../providers/anthropic.js"; import { createOpenAiCompatProvider } from "../providers/openai-compat.js"; import type { PipeTarget } from "../runtime/pipeline.js"; import type { ProviderName } from "../providers/types.js"; +import { whatsappMessages, pushWhatsAppMessage } from "./whatsapp-store.js"; +import { sendWhatsAppText } from "../channels/plugins/whatsapp.js"; // --------------------------------------------------------------------------- // Frame types @@ -77,6 +79,8 @@ export interface ProtocolContext { sessions: JclawSessionStore; plugins: JclawPluginRegistry; runtime: ChatRuntime; + /** Broadcast a frame to all connected WebSocket clients. */ + broadcast: (frame: Record) => void; } // --------------------------------------------------------------------------- @@ -1127,6 +1131,69 @@ async function handleRequest(ctx: ProtocolContext, req: RequestFrameT): Promise< return ok(req.id, { limits: DEFAULT_CONTEXT_LIMITS }); } + // ── whatsapp ────────────────────────────────────────────────────────────── + case "whatsapp.config.get": { + const stored = readConfig().whatsapp ?? {}; + const effective = { ...DEFAULT_WHATSAPP, ...stored }; + // Mask secret tokens before sending to client + return ok(req.id, { + config: { + ...effective, + accessToken: maskKey(effective.accessToken) ?? "", + verifyToken: maskKey(effective.verifyToken) ?? "", + appSecret: maskKey(effective.appSecret) ?? "" + } + }); + } + + case "whatsapp.config.set": { + const stored = readConfig(); + const current = { ...DEFAULT_WHATSAPP, ...(stored.whatsapp ?? {}) }; + if (typeof p.phoneNumberId === "string") current.phoneNumberId = p.phoneNumberId; + if (typeof p.accessToken === "string" && !p.accessToken.includes("…")) current.accessToken = p.accessToken; + if (typeof p.verifyToken === "string" && !p.verifyToken.includes("…")) current.verifyToken = p.verifyToken; + if (typeof p.appSecret === "string" && !p.appSecret.includes("…")) current.appSecret = p.appSecret; + if (p.autoReply !== undefined) current.autoReply = Boolean(p.autoReply); + if (typeof p.autoReplySessionId === "string") current.autoReplySessionId = p.autoReplySessionId; + if (typeof p.autoReplyModel === "string") current.autoReplyModel = p.autoReplyModel; + writeConfig({ ...stored, whatsapp: current }); + return ok(req.id, { saved: true }); + } + + case "whatsapp.send": { + const to = requireStr(req.id, p.to, "to"); + if (typeof to !== "string") return to; + const text = requireStr(req.id, p.text, "text"); + if (typeof text !== "string") return text; + const cfg = { ...DEFAULT_WHATSAPP, ...(readConfig().whatsapp ?? {}) }; + if (!cfg.phoneNumberId || !cfg.accessToken) { + return err(req.id, "WhatsApp not configured — set phoneNumberId and accessToken first"); + } + const result = await sendWhatsAppText(to, text, { + phoneNumberId: cfg.phoneNumberId, + accessToken: cfg.accessToken + }); + const record = { + id: `out-${Date.now().toString(36)}`, + from: cfg.phoneNumberId, + to, + direction: "outbound" as const, + text, + timestamp: Date.now(), + status: result.ok ? ("sent" as const) : ("failed" as const), + error: result.ok ? undefined : result.error + }; + pushWhatsAppMessage(record); + // Broadcast to all connected clients so every open dashboard reflects outbound sends. + ctx.broadcast({ type: "event", event: "whatsapp.message", payload: record }); + return ok(req.id, { result, record }); + } + + case "whatsapp.messages.list": { + const limit = (typeof p.limit === "number" ? p.limit : 100); + return ok(req.id, { messages: whatsappMessages.slice(0, limit) }); + } + // ── legacy ──────────────────────────────────────────────────────────────── case "agent.echo": { const input = requireStr(req.id, p.input, "input"); diff --git a/src/gate/server.ts b/src/gate/server.ts index 28f7d94..8aaf53f 100644 --- a/src/gate/server.ts +++ b/src/gate/server.ts @@ -1,16 +1,19 @@ import express from "express"; import { WebSocketServer } from "ws"; import { createServer } from "http"; +import { createHmac, timingSafeEqual } from "crypto"; import { fileURLToPath } from "url"; import { join, dirname } from "path"; import { initPluginRegistry } from "../plugins/registry.js"; import { initSessionStore } from "./sessions.js"; import { handleWsConnection } from "./protocol.js"; import { initProviderRegistry } from "../providers/registry.js"; -import { readConfig, mergeWithEnv } from "../storage/config.js"; +import { readConfig, mergeWithEnv, DEFAULT_WHATSAPP } from "../storage/config.js"; import type { ProviderConfig } from "../providers/types.js"; import type { ChatRuntime } from "../runtime/chat.js"; import { getMcpClientManager } from "../mcp/client-manager.js"; +import { pushWhatsAppMessage, type WhatsAppMessage } from "./whatsapp-store.js"; +export type { WhatsAppMessage }; export interface JclawGateOptions { port: number; @@ -47,8 +50,17 @@ export async function startJclawGate(options: JclawGateOptions) { const runtime: ChatRuntime = { providers, mcpClientManager }; const wss = new WebSocketServer({ server: httpServer }); + + /** Broadcast a JSON-serialisable frame to every connected WebSocket client. */ + function broadcast(frame: Record): void { + const msg = JSON.stringify(frame); + wss.clients.forEach((client) => { + if (client.readyState === 1) client.send(msg); + }); + } + wss.on("connection", (socket) => { - handleWsConnection({ socket, sessions, plugins, runtime }); + handleWsConnection({ socket, sessions, plugins, runtime, broadcast }); }); app.get("/health", (_req, res) => { @@ -63,6 +75,97 @@ export async function startJclawGate(options: JclawGateOptions) { }); }); + // ── WhatsApp webhook ─────────────────────────────────────────────────────── + + app.get("/webhook/whatsapp", (req, res) => { + const cfg = { ...DEFAULT_WHATSAPP, ...(readConfig().whatsapp ?? {}) }; + const mode = req.query["hub.mode"]; + const token = req.query["hub.verify_token"]; + const rawChallenge = req.query["hub.challenge"]; + // Sanitize challenge: Meta sends a numeric string; reject anything else to prevent reflected XSS. + const challenge = typeof rawChallenge === "string" && /^\d+$/.test(rawChallenge) + ? rawChallenge + : ""; + if (mode === "subscribe" && token === cfg.verifyToken && challenge) { + res.status(200).type("text/plain").send(challenge); + } else { + res.status(403).send("Forbidden"); + } + }); + + app.post("/webhook/whatsapp", express.json({ + verify: (req, _res, buf) => { + // Stash the raw body buffer for signature verification below. + (req as Record).rawBody = buf; + } + }), (req, res) => { + // ── Signature verification ───────────────────────────────────────────── + const cfg = { ...DEFAULT_WHATSAPP, ...(readConfig().whatsapp ?? {}) }; + if (cfg.appSecret) { + const sigHeader = req.headers["x-hub-signature-256"]; + const rawBody = (req as Record).rawBody as Buffer | undefined; + if (!sigHeader || !rawBody) { + res.status(403).send("Forbidden"); + return; + } + const hmac = createHmac("sha256", cfg.appSecret).update(rawBody).digest("hex"); + const expected = Buffer.from(`sha256=${hmac}`); + const received = Buffer.from(String(sigHeader)); + if (expected.length !== received.length || !timingSafeEqual(expected, received)) { + res.status(403).send("Forbidden"); + return; + } + } + + res.status(200).json({ ok: true }); + + try { + const body = req.body as Record; + const entries = (body.entry as unknown[]) ?? []; + for (const entry of entries) { + const e = entry as Record; + const changes = (e.changes as unknown[]) ?? []; + for (const change of changes) { + const c = change as Record; + const value = c.value as Record | undefined; + if (!value) continue; + const messages = (value.messages as unknown[]) ?? []; + for (const msg of messages) { + const m = msg as Record; + const from = String(m.from ?? "").trim(); + const id = String(m.id ?? "").trim(); + // Skip records that are missing required identity fields. + if (!from || !id) continue; + const ts = Number(m.timestamp ?? Math.floor(Date.now() / 1000)); + const type = String(m.type ?? "text"); + let text = ""; + if (type === "text") { + const textObj = m.text as Record | undefined; + text = String(textObj?.body ?? ""); + } else { + text = `[${type} message]`; + } + + const record: WhatsAppMessage = { + id, + from, + direction: "inbound", + text, + timestamp: ts * 1000, + status: "received" + }; + pushWhatsAppMessage(record); + + // Broadcast to all open WebSocket clients + broadcast({ type: "event", event: "whatsapp.message", payload: record }); + } + } + } + } catch (e) { + console.error("[JCLAW] WhatsApp webhook parse error", e); + } + }); + const dashboardDir = join(__dirname, "../../web/dist"); app.use(express.static(dashboardDir)); app.get("*", (_req, res) => { diff --git a/src/gate/whatsapp-store.ts b/src/gate/whatsapp-store.ts new file mode 100644 index 0000000..56f0ffd --- /dev/null +++ b/src/gate/whatsapp-store.ts @@ -0,0 +1,28 @@ +/** Shared in-process store for WhatsApp messages (inbound + outbound). */ + +export interface WhatsAppMessage { + id: string; + from: string; + to?: string; + direction: "inbound" | "outbound"; + text: string; + timestamp: number; + status: "received" | "sent" | "failed"; + error?: string; +} + +const MAX_MESSAGES = 500; + +/** Mutable array shared between the webhook handler and protocol handlers. */ +export const whatsappMessages: WhatsAppMessage[] = []; + +/** + * Prepend a message to the store, enforcing the MAX_MESSAGES cap. + * Always use this instead of mutating `whatsappMessages` directly. + */ +export function pushWhatsAppMessage(msg: WhatsAppMessage): void { + whatsappMessages.unshift(msg); + if (whatsappMessages.length > MAX_MESSAGES) { + whatsappMessages.length = MAX_MESSAGES; + } +} diff --git a/src/storage/config.ts b/src/storage/config.ts index 84bf299..04f02ba 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -48,6 +48,33 @@ export const DEFAULT_REDTEAM: RedTeamConfig = { unlimitedContext: false, }; +export interface WhatsAppConfig { + /** Meta WhatsApp Cloud API phone number ID */ + phoneNumberId: string; + /** Meta system user access token */ + accessToken: string; + /** Webhook verify token (any string you choose in Meta app settings) */ + verifyToken: string; + /** Meta App Secret — used to verify X-Hub-Signature-256 on inbound webhooks */ + appSecret?: string; + /** Auto-reply: if true, incoming messages are forwarded to a JCLAW session and the reply is sent back */ + autoReply: boolean; + /** Session ID to use for auto-replies (omit to create a new session per conversation) */ + autoReplySessionId?: string; + /** Model spec string for auto-replies e.g. "anthropic:claude-sonnet-4-6" */ + autoReplyModel?: string; +} + +export const DEFAULT_WHATSAPP: WhatsAppConfig = { + phoneNumberId: "", + accessToken: "", + verifyToken: "jclaw-verify", + appSecret: undefined, + autoReply: false, + autoReplySessionId: undefined, + autoReplyModel: undefined, +}; + export interface JclawConfig { providers?: ProviderConfig; mcp?: { @@ -55,6 +82,7 @@ export interface JclawConfig { }; sandbox?: Partial; redteam?: Partial; + whatsapp?: Partial; } export function readConfig(): JclawConfig { diff --git a/web/src/App.tsx b/web/src/App.tsx index bc0162d..c3ba708 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,7 @@ import Datasets from "./pages/Datasets.tsx"; import FineTune from "./pages/FineTune.tsx"; import Evals from "./pages/Evals.tsx"; import EmbedSearch from "./pages/EmbedSearch.tsx"; +import WhatsApp from "./pages/WhatsApp.tsx"; export default function App() { const [sidebarOpen, setSidebarOpen] = useState(false); @@ -95,6 +96,11 @@ export default function App() { Embed Search +
channels
+ "nav-item" + (isActive ? " active" : "")} onClick={closeSidebar}> + WhatsApp + +
config
"nav-item" + (isActive ? " active" : "")} onClick={closeSidebar}> Providers @@ -141,6 +147,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/web/src/pages/Overview.tsx b/web/src/pages/Overview.tsx index 194b044..8345d63 100644 --- a/web/src/pages/Overview.tsx +++ b/web/src/pages/Overview.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { call } from "../ws.ts"; +import { call, onEvent } from "../ws.ts"; import { Link } from "react-router-dom"; interface Stats { @@ -39,6 +39,25 @@ interface RedTeamStatus { bypassInjectionCheck: boolean; } +interface WhatsAppStatus { + configured: boolean; + phoneNumberId: string; + autoReply: boolean; +} + +interface McpStatus { + serverCount: number; + toolCount: number; + connectedCount: number; +} + +interface LiveProcessing { + chunksThisSec: number; + totalOutputToday: number; + lastModel: string | null; + lastProvider: string | null; +} + function fmtNum(n: number): string { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; if (n >= 1_000) return (n / 1_000).toFixed(1) + "K"; @@ -56,6 +75,39 @@ function fmtDate(iso: string): string { return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); } +function FrameworkBadge({ label, active, detail, to, color }: { + label: string; active: boolean; detail: string; to: string; color?: string; +}) { + const c = color ?? (active ? "var(--accent)" : "var(--border)"); + return ( + +
+
+
+ + {label} + +
+
{detail}
+
+ + ); +} + export default function Overview() { const [stats, setStats] = useState(null); const [providers, setProviders] = useState([]); @@ -63,6 +115,10 @@ export default function Overview() { const [recentSessions, setRecentSessions] = useState([]); const [sandbox, setSandbox] = useState(null); const [redTeam, setRedTeam] = useState(null); + const [whatsapp, setWhatsApp] = useState(null); + const [mcp, setMcp] = useState(null); + const [live, setLive] = useState({ chunksThisSec: 0, totalOutputToday: 0, lastModel: null, lastProvider: null }); + const [recentTokens, setRecentTokens] = useState<{ ts: number }[]>([]); const [error, setError] = useState(null); useEffect(() => { @@ -76,15 +132,76 @@ export default function Overview() { call<{ redteam: RedTeamStatus }>("redteam.get") .then((r) => setRedTeam(r.redteam)) .catch(() => {}); + call<{ config: { phoneNumberId: string; accessToken: string; autoReply: boolean } }>("whatsapp.config.get") + .then((r) => setWhatsApp({ + configured: Boolean(r.config.phoneNumberId && r.config.accessToken), + phoneNumberId: r.config.phoneNumberId, + autoReply: r.config.autoReply, + })) + .catch(() => {}); + call<{ servers: Array<{ status: string; tools?: unknown[] }> }>("mcp.servers.list") + .then((r) => { + const connected = r.servers.filter((s) => s.status === "connected"); + const toolCount = connected.reduce((acc, s) => acc + (s.tools?.length ?? 0), 0); + setMcp({ serverCount: r.servers.length, connectedCount: connected.length, toolCount }); + }) + .catch(() => {}); setPinging(true); call<{ providers: ProviderPing[] }>("providers.ping") .then((r) => setProviders(r.providers)) .catch((e: Error) => setError(e.message)) .finally(() => setPinging(false)); + + // Seed "output today" from persisted metrics (midnight cutoff). + const midnight = new Date(); + midnight.setHours(0, 0, 0, 0); + call<{ aggregations: Array<{ sampleCount: number; avgOutputTokens: number }> }>( + "metrics.aggregation", + { fromTs: midnight.getTime() } + ).then((r) => { + const todayTotal = r.aggregations.reduce( + (acc, a) => acc + Math.round(a.avgOutputTokens * a.sampleCount), 0 + ); + setLive((prev) => ({ ...prev, totalOutputToday: todayTotal })); + }).catch(() => {}); + + // Real-time token stream tracking + const offEvent = onEvent((event, payload) => { + if (event === "chat.token") { + const p = payload as { token: string }; + if ((p.token?.length ?? 0) > 0) { + setRecentTokens((prev) => { + const now = Date.now(); + return [...prev.filter((t) => now - t.ts < 5_000), { ts: now }]; + }); + } + } + if (event === "metrics.sample") { + const rec = payload as { outputTokens?: number; model?: string; provider?: string }; + if (rec.outputTokens) { + setLive((prev) => ({ + ...prev, + totalOutputToday: prev.totalOutputToday + (rec.outputTokens ?? 0), + lastModel: rec.model ?? prev.lastModel, + lastProvider: rec.provider ?? prev.lastProvider, + })); + } + } + }); + + // Tick to recompute tokens/sec + const ticker = setInterval(() => { + const now = Date.now(); + setRecentTokens((prev) => prev.filter((t) => now - t.ts < 5_000)); + }, 1000); + + return () => { offEvent(); clearInterval(ticker); }; }, []); const totalTokens = stats ? stats.totalInputTokens + stats.totalOutputTokens : 0; const onlineCount = providers.filter((p) => p.ok).length; + // Count of stream-chunk events in the last 5 s (chat.token events, not individual tokens). + const chunksPerSec = (recentTokens.length / 5).toFixed(1); return (
@@ -131,6 +248,36 @@ export default function Overview() {
+ {/* Live processing bar */} +
+ {[ + { label: "STREAM CHUNKS / SEC", value: chunksPerSec, color: recentTokens.length > 0 ? "var(--green)" : "var(--text3)", pulse: recentTokens.length > 0 }, + { label: "OUTPUT TODAY", value: fmtNum(live.totalOutputToday), color: "var(--accent2)", pulse: false }, + { label: "LAST MODEL", value: live.lastModel ?? "—", color: "var(--accent)", pulse: false }, + { label: "LAST PROVIDER", value: live.lastProvider ?? "—", color: "var(--accent2)", pulse: false }, + ].map(({ label, value, color, pulse }) => ( +
+
+ {pulse && ( +
+ )} +
{label}
+
+
{value}
+
+ ))} +
+ {/* Quick actions */}
Quick Actions
(
+ {/* Framework Status */} +
Framework Status
+
+ + + 0} + detail={mcp + ? `${mcp.connectedCount}/${mcp.serverCount} servers · ${mcp.toolCount} tools` + : "Loading…"} + to="/mcp" + color="var(--accent2)" + /> + + + + + +
+ {/* Recent sessions */} {recentSessions.length > 0 && ( <> diff --git a/web/src/pages/WhatsApp.tsx b/web/src/pages/WhatsApp.tsx new file mode 100644 index 0000000..d17e65d --- /dev/null +++ b/web/src/pages/WhatsApp.tsx @@ -0,0 +1,387 @@ +import { useEffect, useState, useRef } from "react"; +import { call, onEvent } from "../ws.ts"; + +interface WhatsAppConfig { + phoneNumberId: string; + accessToken: string; + verifyToken: string; + appSecret?: string; + autoReply: boolean; + autoReplySessionId?: string; + autoReplyModel?: string; +} + +interface WaMessage { + id: string; + from: string; + to?: string; + direction: "inbound" | "outbound"; + text: string; + timestamp: number; + status: "received" | "sent" | "failed"; + error?: string; +} + +const DEFAULT_CFG: WhatsAppConfig = { + phoneNumberId: "", + accessToken: "", + verifyToken: "jclaw-verify", + appSecret: "", + autoReply: false, + autoReplySessionId: "", + autoReplyModel: "", +}; + +function fmtTime(ms: number): string { + const d = new Date(ms); + return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function StatusBadge({ status }: { status: WaMessage["status"] }) { + const color = status === "sent" ? "var(--green)" : status === "failed" ? "var(--red)" : "var(--accent2)"; + return ( + + {status} + + ); +} + +export default function WhatsApp() { + const [cfg, setCfg] = useState(DEFAULT_CFG); + const [saved, setSaved] = useState(false); + const [saving, setSaving] = useState(false); + const [messages, setMessages] = useState([]); + const [sendTo, setSendTo] = useState(""); + const [sendText, setSendText] = useState(""); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const [loading, setLoading] = useState(true); + const [configError, setConfigError] = useState(null); + const [rawToken, setRawToken] = useState(""); + const [rawAppSecret, setRawAppSecret] = useState(""); + const bottomRef = useRef(null); + + useEffect(() => { + setLoading(true); + Promise.all([ + call<{ config: WhatsAppConfig }>("whatsapp.config.get"), + call<{ messages: WaMessage[] }>("whatsapp.messages.list", { limit: 100 }) + ]).then(([cfgRes, msgRes]) => { + setCfg(cfgRes.config); + setMessages(msgRes.messages); + }).catch((e: Error) => setConfigError(e.message)) + .finally(() => setLoading(false)); + + const offEvent = onEvent((event, payload) => { + if (event === "whatsapp.message") { + const msg = payload as WaMessage; + setMessages((prev) => { + const exists = prev.some((m) => m.id === msg.id); + if (exists) return prev; + return [msg, ...prev].slice(0, 500); + }); + } + }); + + return () => { offEvent(); }; + }, []); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + async function handleSave() { + setSaving(true); + setSaved(false); + try { + const payload: Record = { + phoneNumberId: cfg.phoneNumberId, + autoReply: cfg.autoReply, + autoReplySessionId: cfg.autoReplySessionId, + autoReplyModel: cfg.autoReplyModel, + }; + // Only send secrets if they are new plaintext values (not masked placeholders) + if (rawToken) payload.accessToken = rawToken; + if (rawAppSecret) payload.appSecret = rawAppSecret; + if (cfg.verifyToken && !cfg.verifyToken.includes("…")) payload.verifyToken = cfg.verifyToken; + await call("whatsapp.config.set", payload); + setSaved(true); + setRawToken(""); + setRawAppSecret(""); + setTimeout(() => setSaved(false), 2500); + } catch (e: unknown) { + setConfigError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + } + + async function handleSend() { + if (!sendTo.trim() || !sendText.trim()) return; + setSending(true); + setSendError(null); + try { + await call("whatsapp.send", { to: sendTo.trim(), text: sendText.trim() }); + setSendText(""); + } catch (e: unknown) { + setSendError(e instanceof Error ? e.message : "Send failed"); + } finally { + setSending(false); + } + } + + const webhookUrl = `${window.location.origin}/webhook/whatsapp`; + const configured = cfg.phoneNumberId && cfg.accessToken; + + if (loading) return
Loading WhatsApp config...
; + + return ( +
+ {configError &&
{configError}
} + + {/* Header */} +
+
+ Channel Integration +
+
+
WhatsApp Business
+
+ {configured ? "CONFIGURED" : "NOT CONFIGURED"} +
+
+
+ Meta WhatsApp Business Cloud API · Webhook-based inbound · Real-time message log +
+
+ +
+ + {/* Config panel */} +
+
Configuration
+
+ +
+
PHONE NUMBER ID
+ setCfg({ ...cfg, phoneNumberId: e.target.value })} + /> +
+ Found in Meta App → WhatsApp → API Setup +
+
+ +
+
ACCESS TOKEN
+ setRawToken(e.target.value)} + autoComplete="off" + /> +
+ Stored in ~/.jclaw/config.json +
+
+ +
+
WEBHOOK VERIFY TOKEN
+ setCfg({ ...cfg, verifyToken: e.target.value })} + /> +
+ +
+
APP SECRET
+ setRawAppSecret(e.target.value)} + autoComplete="off" + /> +
+ Optional — enables X-Hub-Signature-256 validation on inbound webhooks +
+
+ +
+
WEBHOOK URL
+
+ {webhookUrl} +
+
+ Paste this into Meta App → WhatsApp → Configuration → Webhook URL +
+
+ +
+
AUTO-REPLY
+ + {cfg.autoReply && ( +
+ setCfg({ ...cfg, autoReplySessionId: e.target.value })} + /> + setCfg({ ...cfg, autoReplyModel: e.target.value })} + /> +
+ )} +
+ + +
+ + {/* Test send */} +
Test Send
+
+ {sendError &&
{sendError}
} + setSendTo(e.target.value)} + /> +