From 5c504e39d22c9060b79c97f9ff2a12a474ab8237 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:23:49 +0000 Subject: [PATCH 1/3] feat: add WhatsApp channel, framework status UI, live processing bar, docs Agent-Logs-Url: https://github.com/Jacobcdsmith/jclaw-framework/sessions/18031366-b161-48a5-8ad1-7965bc84e974 Co-authored-by: Jacobcdsmith <88069592+Jacobcdsmith@users.noreply.github.com> --- README.md | 84 ++++++- replit.md | 49 ++++- src/channels/plugins/whatsapp.ts | 66 ++++++ src/channels/types.ts | 1 + src/gate/protocol.ts | 64 +++++- src/gate/server.ts | 75 ++++++- src/gate/whatsapp-store.ts | 15 ++ src/storage/config.ts | 25 +++ web/src/App.tsx | 7 + web/src/pages/Overview.tsx | 200 ++++++++++++++++- web/src/pages/WhatsApp.tsx | 365 +++++++++++++++++++++++++++++++ web/tsconfig.tsbuildinfo | 2 +- 12 files changed, 930 insertions(+), 23 deletions(-) create mode 100644 src/channels/plugins/whatsapp.ts create mode 100644 src/gate/whatsapp-store.ts create mode 100644 web/src/pages/WhatsApp.tsx diff --git a/README.md b/README.md index 065cd69..f96d06f 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,63 @@ 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 + +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`) + +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 is masked) | +| `whatsapp.config.set` | Update Phone Number ID, access token, verify token, 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 +289,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 +340,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..dda26d8 --- /dev/null +++ b/src/channels/plugins/whatsapp.ts @@ -0,0 +1,66 @@ +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. Use `as any` cast to avoid requiring lib:"dom". +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const nodeFetch = (globalThis as any).fetch as (url: string, init?: Record) => Promise<{ ok: boolean; status: number; statusText: string; json: () => Promise }>; + +export interface WhatsAppSendOptions { + phoneNumberId: string; + accessToken: string; +} + +export async function sendWhatsAppText( + to: string, + text: string, + opts: WhatsAppSendOptions +): Promise { + 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 nodeFetch(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..c4417c8 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 } from "./whatsapp-store.js"; +import { sendWhatsAppText } from "../channels/plugins/whatsapp.js"; // --------------------------------------------------------------------------- // Frame types @@ -1127,6 +1129,66 @@ 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 the access token before sending to client + return ok(req.id, { + config: { + ...effective, + accessToken: maskKey(effective.accessToken) ?? "" + } + }); + } + + 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") current.verifyToken = p.verifyToken; + 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 as string | undefined) + }; + whatsappMessages.unshift(record); + if (whatsappMessages.length > 500) whatsappMessages.length = 500; + ctx.socket.send(JSON.stringify({ 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..78636a3 100644 --- a/src/gate/server.ts +++ b/src/gate/server.ts @@ -7,10 +7,12 @@ 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 { whatsappMessages, type WhatsAppMessage } from "./whatsapp-store.js"; +export type { WhatsAppMessage }; export interface JclawGateOptions { port: number; @@ -63,6 +65,77 @@ 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 challenge = req.query["hub.challenge"]; + if (mode === "subscribe" && token === cfg.verifyToken) { + res.status(200).send(challenge); + } else { + res.status(403).send("Forbidden"); + } + }); + + app.post("/webhook/whatsapp", express.json(), (req, res) => { + 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 ?? ""); + const id = String(m.id ?? ""); + 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" + }; + whatsappMessages.unshift(record); + if (whatsappMessages.length > 500) whatsappMessages.length = 500; + + // Broadcast to all open WebSocket clients + wss.clients.forEach((client) => { + if (client.readyState === 1) { + client.send(JSON.stringify({ + 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..93e0223 --- /dev/null +++ b/src/gate/whatsapp-store.ts @@ -0,0 +1,15 @@ +/** 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; +} + +/** Mutable array shared between the webhook handler and protocol handlers. */ +export const whatsappMessages: WhatsAppMessage[] = []; diff --git a/src/storage/config.ts b/src/storage/config.ts index 84bf299..bfbac43 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -48,6 +48,30 @@ 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; + /** 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", + autoReply: false, + autoReplySessionId: undefined, + autoReplyModel: undefined, +}; + export interface JclawConfig { providers?: ProviderConfig; mcp?: { @@ -55,6 +79,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..653ea9f 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 { + tokensThisSec: 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({ tokensThisSec: 0, totalOutputToday: 0, lastModel: null, lastProvider: null }); + const [recentTokens, setRecentTokens] = useState<{ ts: number }[]>([]); const [error, setError] = useState(null); useEffect(() => { @@ -76,15 +132,62 @@ 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)); + + // 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!, + 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; + const tokensPerSec = (recentTokens.length / 5).toFixed(1); return (
@@ -131,6 +234,36 @@ export default function Overview() {
+ {/* Live processing bar */} +
+ {[ + { label: "TOKENS / SEC", value: tokensPerSec, 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..f2e09b3 --- /dev/null +++ b/web/src/pages/WhatsApp.tsx @@ -0,0 +1,365 @@ +import { useEffect, useState, useRef } from "react"; +import { call, onEvent } from "../ws.ts"; + +interface WhatsAppConfig { + phoneNumberId: string; + accessToken: string; + verifyToken: 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", + 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 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, + verifyToken: cfg.verifyToken, + autoReply: cfg.autoReply, + autoReplySessionId: cfg.autoReplySessionId, + autoReplyModel: cfg.autoReplyModel, + }; + if (rawToken) payload.accessToken = rawToken; + await call("whatsapp.config.set", payload); + setSaved(true); + setRawToken(""); + 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 encrypted in ~/.jclaw/config.json +
+
+ +
+
WEBHOOK VERIFY TOKEN
+ setCfg({ ...cfg, verifyToken: e.target.value })} + /> +
+ +
+
WEBHOOK URL
+
+ {webhookUrl} +
+
+ Paste this into Meta App → WhatsApp → Configuration → Webhook URL +
+
+ +
+
AUTO-REPLY
+
+ + {/* Test send */} +
Test Send
+
+ {sendError &&
{sendError}
} + setSendTo(e.target.value)} + /> +