feat: WhatsApp channel integration + framework status dashboard with live processing metrics#7
Conversation
… 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>
…d type cast 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>
There was a problem hiding this comment.
Pull request overview
Adds a new first-class WhatsApp channel (config, send, webhook ingest, live log) and extends the dashboard Overview with real-time “processing” indicators and a framework status panel.
Changes:
- Implement WhatsApp Cloud API outbound adapter, webhook endpoints, and in-memory message store with new
whatsapp.*RPC methods. - Add
/whatsappdashboard page and wire it into the sidebar + routes. - Add Overview live processing bar + Framework Status badges driven by WebSocket events and existing RPC calls; update docs accordingly.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| web/tsconfig.tsbuildinfo | Updated TS incremental build info to include new page entry (but is a build artifact). |
| web/src/pages/WhatsApp.tsx | New WhatsApp configuration + test-send + live message log UI via WS events/RPC. |
| web/src/pages/Overview.tsx | Adds live processing bar (tokens/sec etc.) and Framework Status badge panel; subscribes to WS events. |
| web/src/App.tsx | Adds WhatsApp route and new sidebar “channels” section entry. |
| src/storage/config.ts | Introduces WhatsAppConfig + defaults and adds it to JclawConfig. |
| src/gate/whatsapp-store.ts | Adds shared in-process WhatsApp message array for webhook + RPC handlers. |
| src/gate/server.ts | Adds GET/POST /webhook/whatsapp handlers; broadcasts inbound messages to WS clients. |
| src/gate/protocol.ts | Adds whatsapp.config.get/set, whatsapp.send, whatsapp.messages.list RPC methods. |
| src/channels/types.ts | Extends DeliveryResult with optional error field. |
| src/channels/plugins/whatsapp.ts | New WhatsApp outbound adapter calling Meta Graph API /{phoneNumberId}/messages. |
| replit.md | Updates project/docs to include WhatsApp integration and dashboard additions. |
| README.md | Updates feature list, architecture diagram, pages table, and adds WhatsApp setup + protocol reference. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -1 +1 @@ | |||
| {"root":["./src/App.tsx","./src/main.tsx","./src/ws.ts","./src/pages/Activity.tsx","./src/pages/Chat.tsx","./src/pages/Datasets.tsx","./src/pages/EmbedSearch.tsx","./src/pages/Evals.tsx","./src/pages/FineTune.tsx","./src/pages/Mcp.tsx","./src/pages/Metrics.tsx","./src/pages/Overview.tsx","./src/pages/Prompts.tsx","./src/pages/Providers.tsx","./src/pages/Sandbox.tsx","./src/pages/Search.tsx","./src/pages/SessionDetail.tsx","./src/pages/Sessions.tsx","./src/pages/Templates.tsx","./src/pages/Terminal.tsx"],"version":"5.9.3"} No newline at end of file | |||
| {"root":["./src/App.tsx","./src/main.tsx","./src/ws.ts","./src/pages/Activity.tsx","./src/pages/Chat.tsx","./src/pages/Datasets.tsx","./src/pages/EmbedSearch.tsx","./src/pages/Evals.tsx","./src/pages/FineTune.tsx","./src/pages/Mcp.tsx","./src/pages/Metrics.tsx","./src/pages/Overview.tsx","./src/pages/Prompts.tsx","./src/pages/Providers.tsx","./src/pages/Sandbox.tsx","./src/pages/Search.tsx","./src/pages/SessionDetail.tsx","./src/pages/Sessions.tsx","./src/pages/Templates.tsx","./src/pages/Terminal.tsx","./src/pages/WhatsApp.tsx"],"errors":true,"version":"6.0.2"} No newline at end of file | |||
There was a problem hiding this comment.
tsconfig.tsbuildinfo is a TypeScript incremental build artifact and typically should not be committed (it creates noisy diffs and can be machine-specific). Consider removing it from git and adding it to .gitignore (or configuring the web build to not emit it in tracked paths).
| {"root":["./src/App.tsx","./src/main.tsx","./src/ws.ts","./src/pages/Activity.tsx","./src/pages/Chat.tsx","./src/pages/Datasets.tsx","./src/pages/EmbedSearch.tsx","./src/pages/Evals.tsx","./src/pages/FineTune.tsx","./src/pages/Mcp.tsx","./src/pages/Metrics.tsx","./src/pages/Overview.tsx","./src/pages/Prompts.tsx","./src/pages/Providers.tsx","./src/pages/Sandbox.tsx","./src/pages/Search.tsx","./src/pages/SessionDetail.tsx","./src/pages/Sessions.tsx","./src/pages/Templates.tsx","./src/pages/Terminal.tsx","./src/pages/WhatsApp.tsx"],"errors":true,"version":"6.0.2"} |
| autoComplete="off" | ||
| /> | ||
| <div style={{ fontSize: "10px", color: "var(--text3)", marginTop: "3px" }}> | ||
| Stored encrypted in <code>~/.jclaw/config.json</code> |
There was a problem hiding this comment.
This UI copy claims the access token is “Stored encrypted”, but src/storage/config.ts currently writes ~/.jclaw/config.json as plain JSON via writeFileSync with no encryption. Please adjust the text to accurately describe storage (or implement encryption at rest before claiming it).
| Stored encrypted in <code>~/.jclaw/config.json</code> | |
| Stored in <code>~/.jclaw/config.json</code> |
| // 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 }]; | ||
| }); |
There was a problem hiding this comment.
The live “TOKENS / SEC” value is derived from counting chat.token events over the last 5 seconds, but chat.token payloads are streaming chunks (and can even be the full message in non-streaming fallback), not guaranteed to be a single token. Consider renaming the metric (e.g. “stream chunks/sec”) or emitting an explicit token count from the server if you want true tokens/sec.
| 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, | ||
| })); | ||
| } |
There was a problem hiding this comment.
“OUTPUT TODAY” is currently computed by incrementing totalOutputToday from incoming metrics.sample events starting at 0 when the page loads, so it reflects “output tokens since this page load” (and only when outputTokens is truthy), not “today”. Please either relabel it or source a real ‘today’ aggregate from the backend (e.g. query persisted metrics with a midnight cutoff).
| /** 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[] = []; |
There was a problem hiding this comment.
This in-process store is described as a “ring buffer (max 500)”, but the exported whatsappMessages array has no built-in max length enforcement. Either implement push/unshift helpers in this module that enforce the cap, or update the comment so the invariant is accurate.
| const from = String(m.from ?? ""); | ||
| const id = String(m.id ?? ""); |
There was a problem hiding this comment.
If m.id is missing/empty, this code will store messages with id === "". The frontend dedupes by id, so subsequent empty-id messages will be dropped in the UI. Consider validating required fields (at least id and from) and skipping records that don’t meet the expected webhook schema.
| const from = String(m.from ?? ""); | |
| const id = String(m.id ?? ""); | |
| const from = String(m.from ?? "").trim(); | |
| const id = String(m.id ?? "").trim(); | |
| if (!from || !id) continue; |
| // Mask the access token before sending to client | ||
| return ok(req.id, { | ||
| config: { | ||
| ...effective, | ||
| accessToken: maskKey(effective.accessToken) ?? "" |
There was a problem hiding this comment.
whatsapp.config.get masks accessToken, but it returns verifyToken unmasked as part of ...effective. Since the dashboard WS currently has no auth layer, any connected client can read the webhook verify token. Consider masking verifyToken as well (or omitting it and only returning a boolean like hasVerifyToken).
| // Mask the access token before sending to client | |
| return ok(req.id, { | |
| config: { | |
| ...effective, | |
| accessToken: maskKey(effective.accessToken) ?? "" | |
| // Mask secret tokens before sending to client | |
| return ok(req.id, { | |
| config: { | |
| ...effective, | |
| accessToken: maskKey(effective.accessToken) ?? "", | |
| verifyToken: maskKey(effective.verifyToken) ?? "" |
| 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 }); |
There was a problem hiding this comment.
Outbound sends only emit the whatsapp.message event back to the requesting socket (ctx.socket). If multiple dashboards are connected, other clients won’t see outbound messages in real time (while inbound webhook messages are broadcast to all clients). Consider broadcasting outbound records to all connected WS clients for consistency.
| // 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<string, unknown>) => Promise<{ ok: boolean; status: number; statusText: string; json: () => Promise<unknown> }>; | ||
|
|
There was a problem hiding this comment.
nodeFetch is pulled from globalThis.fetch without a runtime guard. If this code runs in an environment where fetch is unavailable/disabled, it will throw at call-time with a cryptic error. Consider checking for globalThis.fetch and returning a clear DeliveryResult error when it’s missing.
| <div | ||
| onClick={() => setCfg({ ...cfg, autoReply: !cfg.autoReply })} | ||
| style={{ | ||
| width: "38px", height: "20px", borderRadius: "10px", flexShrink: 0, | ||
| background: cfg.autoReply ? "var(--green)" : "var(--border)", | ||
| position: "relative", cursor: "pointer", transition: "background 0.2s", | ||
| }} | ||
| > | ||
| <div style={{ | ||
| width: "14px", height: "14px", borderRadius: "50%", background: "#fff", | ||
| position: "absolute", top: "3px", | ||
| left: cfg.autoReply ? "21px" : "3px", transition: "left 0.2s", | ||
| }} /> | ||
| </div> |
There was a problem hiding this comment.
The auto-reply toggle is implemented as a clickable <div> with no keyboard handling or ARIA semantics, so it’s not accessible to keyboard/screen-reader users. Consider using a real <input type="checkbox"> (visually styled) or a <button role="switch" aria-checked={...}> with proper key handlers.
| <div | |
| onClick={() => setCfg({ ...cfg, autoReply: !cfg.autoReply })} | |
| style={{ | |
| width: "38px", height: "20px", borderRadius: "10px", flexShrink: 0, | |
| background: cfg.autoReply ? "var(--green)" : "var(--border)", | |
| position: "relative", cursor: "pointer", transition: "background 0.2s", | |
| }} | |
| > | |
| <div style={{ | |
| width: "14px", height: "14px", borderRadius: "50%", background: "#fff", | |
| position: "absolute", top: "3px", | |
| left: cfg.autoReply ? "21px" : "3px", transition: "left 0.2s", | |
| }} /> | |
| </div> | |
| <input | |
| type="checkbox" | |
| checked={cfg.autoReply} | |
| onChange={(e) => setCfg({ ...cfg, autoReply: e.target.checked })} | |
| style={{ | |
| width: "38px", | |
| height: "20px", | |
| borderRadius: "10px", | |
| flexShrink: 0, | |
| appearance: "none", | |
| WebkitAppearance: "none", | |
| margin: 0, | |
| border: "none", | |
| outline: "none", | |
| background: cfg.autoReply ? "var(--green)" : "var(--border)", | |
| cursor: "pointer", | |
| transition: "background 0.2s", | |
| boxShadow: cfg.autoReply | |
| ? "inset 21px 0 0 3px #fff" | |
| : "inset 3px 0 0 3px #fff", | |
| }} | |
| /> |
Jacobcdsmith
left a comment
There was a problem hiding this comment.
@copilot let's apply suggested edits
…ty, module hygiene Agent-Logs-Url: https://github.com/Jacobcdsmith/jclaw-framework/sessions/5506eb0f-f53b-42ce-b7d8-faf1df3d0666 Co-authored-by: Jacobcdsmith <88069592+Jacobcdsmith@users.noreply.github.com>
All 11 suggested edits applied in commit
|
|
@copilot apply changes based on the comments in this thread |
All changes from that review thread were already applied in commit
|
Adds Meta WhatsApp Business Cloud API as a first-class channel and surfaces real-time framework state across the dashboard — both previously absent from the UI and backend.
Backend
src/channels/plugins/whatsapp.ts— outbound adapter callingPOST /v20.0/{phoneNumberId}/messages; guardsglobalThis.fetchavailability at call-time and returns a descriptive error when unavailable (Node 18+ required)src/gate/whatsapp-store.ts— shared in-process store (max 500) for inbound + outbound messages; cap enforced inside the module via apushWhatsAppMessage()helper used by all callerssrc/gate/server.ts— two new HTTP routes:GET /webhook/whatsapp— Meta hub verification;hub.challengesanitized to/^\d+$/to prevent reflected XSSPOST /webhook/whatsapp— validatesX-Hub-Signature-256HMAC-SHA256 signature usingtimingSafeEqualwhenappSecretis configured; skips messages with emptyidorfrom; broadcastswhatsapp.messageWS event to all connected clients via a sharedbroadcasthelpersrc/gate/protocol.ts— four new RPC methods:whatsapp.config.get(masksaccessToken,verifyToken, andappSecret),whatsapp.config.set,whatsapp.send(broadcasts outbound events to all WS clients),whatsapp.messages.list;ProtocolContextextended with abroadcastcallbacksrc/storage/config.ts—WhatsAppConfiginterface withphoneNumberId,accessToken,verifyToken,appSecret(for webhook signature verification),autoReply,autoReplySessionId,autoReplyModeladded toJclawConfigsrc/channels/types.ts— addederror?: stringtoDeliveryResultFrontend
/whatsapppage — credential form (Phone Number ID, access token, verify token, app secret), webhook URL ready-to-copy, auto-reply toggle (accessible<input type="checkbox" role="switch" aria-checked>), test send, live message log (inbound cyan / outbound amber, real-time via WS)chat.tokenevent count, not individual tokens), "OUTPUT TODAY" seeded frommetrics.aggregationwith a midnight cutoff on page load then incremented by live WS events, last model, last providerFrameworkBadgecards (Sandbox, Red Team, MCP, WhatsApp, Pipeline, Evals, Fine-Tune, Embeddings) with live active/inactive state pulled from existing RPC callsDocs
README.md— 5-step WhatsApp setup guide (including App Secret), updated architecture diagram, complete dashboard pages table, protocol methods reference, updated project structurereplit.md— rewritten to reflect all current features and WhatsApp setup steps.gitignore—web/tsconfig.tsbuildinfoexcluded (build artifact)