diff --git a/.gitignore b/.gitignore index ee970f8..97dfb4e 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ transform.js *.dump *.sql.gz *.backup + +# Internal ops doc — not for the public repo +ForgeMind_CICD_Setup_Guide.pdf diff --git a/README.md b/README.md index 653705d..ed50d77 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,52 @@ In the **Meta dashboard** → **WhatsApp → Configuration → Webhook → Edit* --- +## 🔌 Build agents from Claude (MCP server) + +ForgeChat ships an **MCP server** so you can **build and manage your WhatsApp AI agents by chatting with Claude** (Claude Desktop, or any MCP client) — it asks what you need, looks up your real WhatsApp numbers, AI models, Google Sheets, media and templates, and creates a fully-configured agent for you. No dashboard required. + +### Step 1 — Turn it on and get a key +In ForgeChat, open **Settings → MCP Tools**: +1. Flip the **master switch** on, and enable the **capabilities** you want (discovery, create/update agents, manage tools, delete). +2. Click **Generate key** and copy the `fck_live_…` value — it's shown **once**. + +### Step 2 — Connect Claude (pick one) + +**A) Remote — just a URL (easiest)** +The MCP Tools page shows a ready-to-paste connect URL: + +``` +https://your-domain/api/mcp/http/ +``` + +In Claude (web/desktop/mobile) → **Settings → Connectors → Add custom connector** → paste that URL. Done. + +**B) Local — run the bundled server** +Use the server in [`mcp-server/`](mcp-server/) (`npm install` once), then add this to your `claude_desktop_config.json` +(macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`, Windows: `%APPDATA%\Claude\claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "forgechat-agents": { + "command": "node", + "args": ["/path/to/ForgeChat/mcp-server/src/index.js"], + "env": { + "FORGECHAT_API_URL": "https://your-domain/api/mcp/v1", + "FORGECHAT_API_KEY": "fck_live_PASTE_YOUR_KEY" + } + } + } +} +``` + +### Step 3 — Use it +Fully quit and reopen Claude, then say **“create a ForgeChat agent”** — it walks you through the setup questions and builds the agent. Each capability is gated by the toggles in **Settings → MCP Tools** (a disabled one returns a clear error), and the key can be revoked any time from the same page. + +> Replace `your-domain` with wherever ForgeChat is hosted. The bearer key works over the internet; your ForgeChat login cookie is never involved. See [`mcp-server/README.md`](mcp-server/README.md) for the full tool list and debugging tips. + +--- + ## 🔄 Keeping it running **Update to the latest version** (run on your server, inside the `ForgeChat` folder): diff --git a/SKILL.md b/SKILL.md index e0ad30d..0452d01 100644 --- a/SKILL.md +++ b/SKILL.md @@ -115,7 +115,7 @@ Keep all eight design principles regardless of flow. --- -## Phase 4 — Build the agent in ForgeChat over MCP (optional) +## Phase 4 — Build the agent in ForgeChat over MCP Phases 1–3 produce the **system prompt**. If a ForgeChat **MCP connector** is connected, you can also **create and configure the agent directly in ForgeChat** instead of pasting the prompt by hand — Claude drives the build through the MCP tools. @@ -130,12 +130,12 @@ Build flow: 4. Trigger: `"any"` (every message) or `"keyword"` (ask keyword, match type exact/contains/starts, case sensitivity, session minutes — default 30). 5. Inbound understanding: set `transcribeAudio:true` to transcribe voice notes (needs an OpenAI key), `acceptImages:true` to let it see images (pick a vision-capable model, e.g. GPT-4o / Claude). 6. Tools: - - **Google Sheets** (per connected Google account): `list_google_accounts` → `search_spreadsheets` (with that `googleAccountId`) → `list_sheet_tabs` → ask which ops (read/append/update), then `add_google_sheets_tool` (`googleAccountId` + spreadsheet + tab + ops). + - **Google Sheets** (per connected Google account): `list_google_accounts` → `search_spreadsheets` (with that `googleAccountId`) → `list_sheet_tabs` → if the agent will LOG rows, call `read_sheet_values` (range `A1:Z1`) to read the real header row and map fields to the exact column names. Ask which ops to allow — read / append / update / **upsert** — then `add_google_sheets_tool` (`googleAccountId` + spreadsheet + tab + ops). **For logging a contact's data, prefer `upsert`:** it finds the contact's existing row by a key column (e.g. phone number) and updates only the columns you name, or adds a new row if none exists — so a contact never gets duplicate rows and you never track row numbers or column order. - **HTTP request** (call an external API / device / webhook): `add_http_tool` with method + URL (use `{name}` for path params) + static auth headers + the params the AI fills (each: name, location path/query/body/header, type, description, required). 7. Media groups (optional): for each, a "when to send" **description** + media (`list_media`) and/or an approved template (`list_templates` → `get_template` to confirm content before using its id). 8. Summarize the full config, get a "yes", then `create_agent`; attach any tools; report the new agent id and offer to activate it (Go Live). -**MCP tools available** — discovery: `list_wa_accounts`, `list_models`, `list_google_accounts`, `search_spreadsheets`, `list_sheet_tabs`, `list_media`, `list_templates`, `get_template`, `list_agents`, `get_agent`. Mutations: `create_agent`, `update_agent`, `add_google_sheets_tool`, `add_http_tool`, `add_tool`, `update_tool`, `delete_tool`, `delete_agent`. The master switch + per-capability toggles in Admin Settings → MCP Tools gate what's allowed (a disabled capability returns 403); always confirm destructive actions first. +**MCP tools available** — discovery: `list_wa_accounts`, `list_models`, `list_google_accounts`, `search_spreadsheets`, `list_sheet_tabs`, `read_sheet_values`, `list_media`, `list_templates`, `get_template`, `list_agents`, `get_agent`. Mutations: `create_agent`, `update_agent`, `add_google_sheets_tool`, `add_http_tool`, `add_tool`, `update_tool`, `delete_tool`, `delete_agent`. The master switch + per-capability toggles in Admin Settings → MCP Tools gate what's allowed (a disabled capability returns 403); always confirm destructive actions first. **Constraints to respect** (the server enforces them too): OpenAI / Anthropic providers only; one active agent per WhatsApp number; a keyword-triggered active agent needs a keyword; context window 1–100 messages; max tool iterations 1–20; trigger session 1–1440 minutes; `acceptImages` requires a vision-capable model. diff --git a/backend/db/migrations/056_agent_accept_images.sql b/backend/db/migrations/056_agent_accept_images.sql new file mode 100644 index 0000000..ea30e61 --- /dev/null +++ b/backend/db/migrations/056_agent_accept_images.sql @@ -0,0 +1,7 @@ +-- 056_agent_accept_images.sql +-- Per-agent toggle: when on, the agent "sees" an inbound WhatsApp image by +-- passing the image to its (vision-capable) LLM along with any caption. Mirrors +-- transcribe_audio (migration 054). Additive + idempotent. + +ALTER TABLE coexistence.agents + ADD COLUMN IF NOT EXISTS accept_images BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/db/migrations/057_mcp.sql b/backend/db/migrations/057_mcp.sql new file mode 100644 index 0000000..8ca78fc --- /dev/null +++ b/backend/db/migrations/057_mcp.sql @@ -0,0 +1,25 @@ +-- 057_mcp.sql +-- External MCP access: bearer API keys + a singleton settings row (master switch +-- + per-capability toggles). Mirrors services/mcpService.ensureMcpTables(). +-- Additive + idempotent. + +CREATE TABLE IF NOT EXISTS coexistence.mcp_api_keys ( + id BIGSERIAL PRIMARY KEY, + label TEXT NOT NULL, + key_prefix TEXT NOT NULL, + key_last4 TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, -- sha256(plaintext) hex + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_used_at TIMESTAMPTZ, + created_by BIGINT REFERENCES coexistence.forgecrm_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coexistence.mcp_settings ( + id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + master_enabled BOOLEAN NOT NULL DEFAULT FALSE, + capabilities JSONB NOT NULL DEFAULT '{"discovery":true,"create_agent":true,"update_agent":true,"manage_tools":true,"delete":true}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO coexistence.mcp_settings (id) VALUES (1) ON CONFLICT DO NOTHING; diff --git a/backend/package-lock.json b/backend/package-lock.json index 1237682..837873f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,17 +1,18 @@ { "name": "forgecrm-backend", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgecrm-backend", - "version": "1.1.0", + "version": "1.2.0", "license": "LicenseRef-Sustainable-Use-License", "dependencies": { - "@anthropic-ai/sdk": "^0.100.1", + "@anthropic-ai/sdk": "^0.99.0", + "@modelcontextprotocol/sdk": "^1.29.0", "bcryptjs": "^2.4.3", - "bullmq": "^5.78.0", + "bullmq": "^5.77.6", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -20,20 +21,21 @@ "express-rate-limit": "^7.3.1", "googleapis": "^172.0.0", "helmet": "^7.1.0", - "ioredis": "^5.11.1", + "ioredis": "^5.11.0", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", - "openai": "^6.42.0", - "pg": "^8.21.0" + "openai": "^6.0.0", + "pg": "^8.21.0", + "zod": "^3.25.76" }, "devDependencies": { "nodemon": "^3.1.4" } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.100.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz", - "integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==", + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.99.0.tgz", + "integrity": "sha512-vdicFA9YjtvgpG8rxp39hqW4oxpkdRGPTq0QEts5TZhr2GkLozDduK/GiXrLQ7PzrKYBtIkQFdRW+QBjzlsABg==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1", @@ -89,16 +91,403 @@ "lodash.uniq": "^4.5.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@ioredis/commands": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", "cpu": [ "arm64" ], @@ -109,9 +498,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", "cpu": [ "x64" ], @@ -122,9 +511,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", "cpu": [ "arm" ], @@ -135,9 +524,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", "cpu": [ "arm64" ], @@ -148,9 +537,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", "cpu": [ "x64" ], @@ -161,9 +550,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", "cpu": [ "x64" ], @@ -207,6 +596,39 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -518,14 +940,14 @@ } }, "node_modules/bullmq": { - "version": "5.78.0", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.78.0.tgz", - "integrity": "sha512-tT9jJmbobk9ueEfFc22egLmgwCcMGgOjZ5Y1cvgczBPv1JUmC7iHQVbQtqku2YBE5dE9uzdVpxIrBvL/YAjGwA==", + "version": "5.77.6", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.77.6.tgz", + "integrity": "sha512-WCpSoCD4vWyRD+btOsFrO7iBGInrTgG155gTZCV8qY0Yex2KtsbVtFERx6V1WZ2xWl/5ZxnLar8Z8ufnS4f5jg==", "license": "MIT", "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", - "msgpackr": "2.0.2", + "msgpackr": "2.0.1", "node-abort-controller": "3.1.1", "semver": "7.8.0", "tslib": "2.8.1" @@ -835,6 +1257,20 @@ "node": ">=12.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -1040,6 +1476,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -1140,12 +1597,34 @@ "node": ">=10.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", "license": "Unlicense" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -1525,6 +2004,15 @@ "node": ">=16.0.0" } }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1644,9 +2132,9 @@ "license": "ISC" }, "node_modules/ioredis": { - "version": "5.11.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", - "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.0.tgz", + "integrity": "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==", "license": "MIT", "dependencies": { "@ioredis/commands": "1.10.0", @@ -1688,6 +2176,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1743,12 +2240,33 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -1771,6 +2289,18 @@ "node": ">=16" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2156,18 +2686,18 @@ "license": "MIT" }, "node_modules/msgpackr": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.2.tgz", - "integrity": "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.1.tgz", + "integrity": "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==", "license": "MIT", "optionalDependencies": { - "msgpackr-extract": "^3.0.4" + "msgpackr-extract": "^3.0.2" } }, "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2178,12 +2708,12 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" } }, "node_modules/multer": { @@ -2379,9 +2909,9 @@ } }, "node_modules/openai": { - "version": "6.42.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.42.0.tgz", - "integrity": "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==", + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.41.0.tgz", + "integrity": "sha512-IGWPopZq6Rjoynjfb3NSLf/z2MTw7UiOsm9TAjPGAjUESH7Uq41Trg4QWehBEn58p74i+m7uoRPV2vXcpPXhyA==", "license": "Apache-2.0", "peerDependencies": { "ws": "^8.18.0", @@ -2420,6 +2950,15 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -2528,6 +3067,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -2716,6 +3264,15 @@ "node": ">=4" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -2729,6 +3286,55 @@ "rimraf": "bin.js" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2836,6 +3442,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -3198,6 +3825,21 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3253,6 +3895,24 @@ "engines": { "node": ">= 10" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/backend/package.json b/backend/package.json index e5567d7..ea5be69 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "forgecrm-backend", - "version": "1.1.0", + "version": "1.2.0", "description": "ForgeChat backend API for WhatsApp chat viewer", "private": true, "license": "LicenseRef-Sustainable-Use-License", @@ -11,9 +11,10 @@ "test": "node --test test/" }, "dependencies": { - "@anthropic-ai/sdk": "^0.100.1", + "@anthropic-ai/sdk": "^0.99.0", + "@modelcontextprotocol/sdk": "^1.29.0", "bcryptjs": "^2.4.3", - "bullmq": "^5.78.0", + "bullmq": "^5.77.6", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -22,11 +23,12 @@ "express-rate-limit": "^7.3.1", "googleapis": "^172.0.0", "helmet": "^7.1.0", - "ioredis": "^5.11.1", + "ioredis": "^5.11.0", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", - "openai": "^6.42.0", - "pg": "^8.21.0" + "openai": "^6.0.0", + "pg": "^8.21.0", + "zod": "^3.25.76" }, "devDependencies": { "nodemon": "^3.1.4" diff --git a/backend/src/engine/agentEngine.js b/backend/src/engine/agentEngine.js index 5643b6a..c176ae2 100644 --- a/backend/src/engine/agentEngine.js +++ b/backend/src/engine/agentEngine.js @@ -108,6 +108,48 @@ async function buildToolsForAgent(agent, sendCtx = null) { }); executors[name] = (args) => googleSheets.executeOp({ op: 'update', toolConfig: cfg, args }); } + + if (ops.includes('upsert')) { + const name = `google_sheets_upsert_${row.id}`; + tools.push({ + name, + description: `Find-or-update one row in ${baseDesc} by a key column. PREFER THIS over append/update for logging a contact's enquiry/order/lead: it finds the existing row by key (e.g. their phone number) and writes only the columns you name, or adds a new row if none exists — so a contact never gets duplicate rows and you never track row numbers or column order. Give the key column's exact header + the contact's value for it, plus the fields to write keyed by their EXACT column headers.`, + input_schema: { + type: 'object', + properties: { + key_column: { type: 'string', description: 'Exact header of the column that identifies the row, e.g. "Phone Number".' }, + key_value: { type: ['string', 'number'], description: "The contact's value for the key column (e.g. their phone number)." }, + fields: { type: 'object', description: 'Object of { "Exact Column Header": value } — only these columns are written. e.g. { "Conversation summary": "…", "Query on which facility": "Personal Training", "Status": "Trial Scheduled" }.' }, + }, + required: ['key_column', 'key_value', 'fields'], + }, + }); + executors[name] = (args) => googleSheets.executeOp({ op: 'upsert', toolConfig: cfg, args }); + } + } + + if (row.tool_type === 'http_request') { + const cfg = row.config || {}; + const params = Array.isArray(cfg.params) ? cfg.params : []; + const name = `http_${slugForTool(cfg.label)}_${row.id}`; + + const properties = {}; + const required = []; + for (const p of params) { + properties[p.name] = { + type: p.type === 'number' ? 'number' : p.type === 'boolean' ? 'boolean' : 'string', + description: p.description || `The ${p.name} value.`, + }; + if (p.required) required.push(p.name); + } + + tools.push({ + name, + description: + `${cfg.description}\n(Performs an HTTP ${cfg.method} request to an external system.)`, + input_schema: { type: 'object', properties, required }, + }); + executors[name] = (args) => executeHttpTool(cfg, args || {}); } // Future: gmail_send, calendar_create_event, etc. — same pattern. } @@ -166,9 +208,148 @@ async function buildToolsForAgent(agent, sendCtx = null) { }; } + // CRM write-back tools — only when the agent opts in AND there's a real live + // contact (skip in the test preview, where contactNumber is 'test'). + const liveContact = sendCtx && sendCtx.live && sendCtx.contactNumber && sendCtx.contactNumber !== 'test'; + if (agent.crm_tools_enabled && liveContact) { + const { buildCrmTools, resolveWaNumber } = require('../services/agentCrmTools'); + const waNumber = sendCtx.waNumber || await resolveWaNumber(sendCtx.waAccountId); + if (waNumber) { + const crm = buildCrmTools({ waNumber, contactNumber: sendCtx.contactNumber }); + tools.push(...crm.tools); + Object.assign(executors, crm.executors); + } + } + + // Human handoff — let the agent hand the conversation to a person. + if (agent.handoff_enabled && liveContact) { + const { performHandoff } = require('../services/agentHandoff'); + const { resolveWaNumber } = require('../services/agentCrmTools'); + const waNumber = sendCtx.waNumber || await resolveWaNumber(sendCtx.waAccountId); + if (waNumber) { + tools.push({ + name: 'escalate_to_human', + description: "Hand this conversation to a human team member. Call this when the customer asks to talk to a person, is upset/complaining, asks something you genuinely can't answer, or it's a high-value or sensitive case. AFTER calling it: tell the customer a team member will take over shortly, then STOP — you won't reply again on this chat until a human returns control.", + input_schema: { + type: 'object', + properties: { + reason: { type: 'string', description: 'Short reason for escalating (for the team).' }, + summary: { type: 'string', description: 'A 1–2 line summary of the conversation so the human has context.' }, + }, + required: ['reason'], + }, + }); + executors['escalate_to_human'] = async ({ reason, summary }) => { + const r = await performHandoff({ + agentId: agent.id, + handoffUserIds: agent.handoff_user_ids, + waNumber, + contactNumber: sendCtx.contactNumber, + reason: [reason, summary].filter(Boolean).join(' — '), + by: 'agent', + }); + return { + ok: true, + handed_off: true, + assigned_to: r.assignedUserName || 'the team', + note: 'Tell the customer a team member will take over shortly, then stop replying.', + }; + }; + } + } + return { tools, executors }; } +// Turn an HTTP tool label into a safe tool-name fragment (LLM tool names must +// match ^[a-zA-Z0-9_-]+). Falls back to "call" when nothing usable remains. +function slugForTool(label) { + const s = String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40); + return s || 'call'; +} + +/** + * Execute a configured http_request tool. The admin owns method/url/static + * headers; the LLM supplies the declared params (path → URL substitution, + * query → querystring, body → JSON body, header → request header). Returns a + * compact { ok, status, body } the model can read; never throws (errors come + * back as { ok:false, error } so the LLM can react). + */ +async function executeHttpTool(cfg, args) { + try { + const method = String(cfg.method || 'GET').toUpperCase(); + const params = Array.isArray(cfg.params) ? cfg.params : []; + + let url = String(cfg.url || ''); + const query = new URLSearchParams(); + const bodyObj = {}; + const dynHeaders = {}; + let hasBody = false; + + for (const p of params) { + let val = args[p.name]; + if (val === undefined || val === null || val === '') { + if (p.required) return { ok: false, error: `Missing required parameter "${p.name}".` }; + continue; + } + if (p.type === 'number') { const n = Number(val); if (!Number.isNaN(n)) val = n; } + else if (p.type === 'boolean') { val = val === true || val === 'true' || val === 1 || val === '1'; } + + if (p.in === 'path') { + url = url.replace(new RegExp(`\\{${p.name}\\}`, 'g'), encodeURIComponent(String(val))); + } else if (p.in === 'query') { + query.append(p.name, String(val)); + } else if (p.in === 'header') { + dynHeaders[p.name] = String(val); + } else { // body + bodyObj[p.name] = val; + hasBody = true; + } + } + + const qs = query.toString(); + if (qs) url += (url.includes('?') ? '&' : '?') + qs; + + const headers = {}; + for (const h of (Array.isArray(cfg.headers) ? cfg.headers : [])) { + if (h && h.k) headers[h.k] = h.v; + } + Object.assign(headers, dynHeaders); + + const init = { method, headers }; + if (method !== 'GET' && method !== 'DELETE' && hasBody) { + if (!Object.keys(headers).some(k => k.toLowerCase() === 'content-type')) { + headers['Content-Type'] = 'application/json'; + } + init.body = JSON.stringify(bodyObj); + } + + const ctrl = new AbortController(); + const timeout = Math.max(1000, Math.min(30000, parseInt(cfg.timeout_ms || 10000, 10) || 10000)); + const timer = setTimeout(() => ctrl.abort(), timeout); + let res; + try { + res = await fetch(url, { ...init, signal: ctrl.signal }); + } finally { + clearTimeout(timer); + } + + const raw = await res.text(); + let body; + try { body = JSON.parse(raw); } + catch { body = raw.length > 4000 ? raw.slice(0, 4000) + '…[truncated]' : raw; } + if (typeof body !== 'string') { + const asStr = JSON.stringify(body); + if (asStr.length > 4000) body = asStr.slice(0, 4000) + '…[truncated]'; + } + + return { ok: res.ok, status: res.status, body }; + } catch (err) { + if (err.name === 'AbortError') return { ok: false, error: 'Request timed out.' }; + return { ok: false, error: err.message || 'HTTP request failed.' }; + } +} + /** * Deliver every media item in a group to the contact on WhatsApp. Mirrors the * automation engine's direct-media path: resolve each media_library item to a @@ -451,6 +632,51 @@ async function transcribeInboundIfAudio({ agent, inboundMessageId, agentApiKey } } } +/** + * Load an inbound image as base64 so it can be shown to a vision model. Reads + * the already-downloaded file (media_storage_path) when present, else downloads + * it on demand via the same mediaDownloader the chat UI uses. Returns + * { mime, data } or null (not an image / too large / fetch failed). + */ +async function loadInboundImageBase64({ inboundMessageId }) { + try { + const { rows } = await pool.query( + `SELECT message_id, message_type, media_mime_type, media_storage_path, media_status + FROM coexistence.chat_history WHERE message_id = $1`, + [inboundMessageId], + ); + const msg = rows[0]; + if (!msg || msg.message_type !== 'image') return null; + + const fs = require('fs'); + let absPath = (msg.media_status === 'stored' && msg.media_storage_path) ? msg.media_storage_path : null; + let mime = msg.media_mime_type || 'image/jpeg'; + + if (!absPath) { + const { downloadOne } = require('../services/mediaDownloader'); + const dl = await downloadOne(inboundMessageId); + if (!dl || !dl.ok || !dl.path) { + console.warn('[agentEngine] could not fetch inbound image:', dl && dl.error); + return null; + } + absPath = dl.path; + if (dl.mime) mime = dl.mime; + } + + if (!fs.existsSync(absPath)) return null; + const buf = fs.readFileSync(absPath); + if (!buf || !buf.length) return null; + if (buf.length > 5 * 1024 * 1024) { + console.warn('[agentEngine] inbound image too large for vision:', buf.length); + return null; + } + return { mime, data: buf.toString('base64') }; + } catch (e) { + console.error('[agentEngine] loadInboundImage failed:', e.message); + return null; + } +} + async function recordStep(runId, stepIndex, step) { await pool.query( `INSERT INTO coexistence.agent_run_steps @@ -477,8 +703,14 @@ async function recordStep(runId, stepIndex, step) { */ function withContactContext(systemPrompt, contactNumber) { const base = systemPrompt || ''; - if (!contactNumber) return base; - return `${base}\n\n## Conversation context\n- The customer's WhatsApp number is ${contactNumber}. When you need their mobile number (e.g. saving an order to a sheet), use this exact number — never ask the customer for it.`; + // Always tell the model today's date/time (IST). Without it the LLM guesses, + // and was logging stale dates (e.g. a 2023 date) into the "Date" sheet column. + const now = new Date(); + const today = now.toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' }); // YYYY-MM-DD + const time = now.toLocaleTimeString('en-GB', { timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit' }); + const dateCtx = `\n\n## Current date & time\n- Today is ${today} at ${time} (Asia/Kolkata, IST). Use this whenever you need the current date/time — e.g. a "Date" column when logging to a sheet. Format dates as YYYY-MM-DD unless instructed otherwise.`; + if (!contactNumber) return base + dateCtx; + return `${base}${dateCtx}\n\n## Conversation context\n- The customer's WhatsApp number is ${contactNumber}. When you need their mobile number (e.g. saving an order to a sheet), use this exact number — never ask the customer for it.`; } /** @@ -508,11 +740,20 @@ async function runAgent({ agentId, contactNumber, inboundMessageId, inboundText // (no text body), turn it into text via Whisper before running the LLM loop. let messageText = inboundText; if (!messageText && inboundMessageId && agent.transcribe_audio) { + // May be empty (no transcript) — don't bail yet; it could still be an image. messageText = await transcribeInboundIfAudio({ agent, inboundMessageId, agentApiKey: apiKey }); - if (!messageText) { - console.warn(`[agentEngine] agent ${agent.id}: inbound ${inboundMessageId} had no usable transcript; skipping run.`); - return { skipped: true, reason: 'no_transcript' }; - } + } + + // Vision: when the agent accepts images and the inbound is a picture, load its + // bytes so the (vision-capable) model can actually see it. + let inboundImage = null; + if (inboundMessageId && agent.accept_images) { + inboundImage = await loadInboundImageBase64({ inboundMessageId }); + } + + if (!messageText && !inboundImage) { + console.warn(`[agentEngine] agent ${agent.id}: inbound ${inboundMessageId} had no usable text/transcript/image; skipping run.`); + return { skipped: true, reason: 'nothing_actionable' }; } // Open the run row immediately so a crash mid-loop is still visible in the UI. @@ -548,6 +789,22 @@ async function runAgent({ agentId, contactNumber, inboundMessageId, inboundText currentInboundText: messageText, }); + // If the inbound was an image, make the final user turn multimodal (image + + // caption) so the model sees the picture. + if (inboundImage) { + const caption = (messageText || '').trim(); + const parts = [ + { type: 'image', mime: inboundImage.mime, data: inboundImage.data }, + { type: 'text', text: caption || 'The customer sent this image.' }, + ]; + const last = history[history.length - 1]; + if (last && last.role === 'user' && last.content === caption && caption) { + last.content = parts; + } else { + history.push({ role: 'user', content: parts }); + } + } + const provider = getProvider(agent.ai_provider); const result = await provider.runWithTools({ systemPrompt: withContactContext(agent.system_prompt, contactNumber), @@ -574,6 +831,20 @@ async function runAgent({ agentId, contactNumber, inboundMessageId, inboundText result.finalText || null, runId], ); + // Idle-close bookkeeping: stamp the last run time + mark a summary pending, + // so the close-summary sweeper picks the conversation up once it goes quiet. + if (agent.close_summary_enabled) { + await pool.query( + `UPDATE coexistence.contacts c + SET agent_close_pending = TRUE, agent_last_run_at = NOW() + FROM coexistence.whatsapp_accounts w + WHERE w.id = $1 + AND regexp_replace(c.wa_number, '\\D', '', 'g') = regexp_replace(w.display_phone_number, '\\D', '', 'g') + AND c.contact_number = $2`, + [agent.wa_account_id, contactNumber], + ).catch(e => console.error('[agentEngine] close-pending stamp failed:', e.message)); + } + if (result.finalText) { // Insert an optimistic chat_history row FIRST so the agent's reply shows // up in the Chats UI immediately (status='sending'), then the existing @@ -739,4 +1010,59 @@ async function transcribeForAgent({ agentId, filePath }) { return await transcribeAudioFile({ filePath, apiKey: openaiKey }); } -module.exports = { runAgent, runAgentTest, buildToolsForAgent, buildMessageHistory, transcribeForAgent }; +// Idle-close summary: when a conversation has gone quiet, re-run the agent ONCE +// with a directive to write its final complete summary using its own logging +// tools (Sheets upsert / CRM) — and send NOTHING to the customer. Driven by the +// close-summary sweeper (services/agentCloseSummary.js). +async function runCloseSummary({ agentId, waNumber, contactNumber }) { + const { rows } = await pool.query( + `SELECT a.*, am.provider AS ai_provider, am.api_key_encrypted AS ai_api_key_encrypted + FROM coexistence.agents a + LEFT JOIN coexistence.ai_models am ON am.id = a.ai_model_id + WHERE a.id = $1`, + [agentId], + ); + const agent = rows[0]; + if (!agent || !agent.is_active || !agent.close_summary_enabled || !agent.ai_provider) return { skipped: true }; + const apiKey = pickApiKey(agent); + if (!apiKey) return { skipped: 'no_api_key' }; + + const { tools, executors } = await buildToolsForAgent(agent, { + live: true, waAccountId: agent.wa_account_id, waNumber, contactNumber, + }); + // Only worth a model call if the agent can actually log somewhere. + const canLog = tools.some(t => /google_sheets_(upsert|append|update)|set_contact_field|add_contact_tag/.test(t.name)); + const history = await buildMessageHistory({ + waAccountId: agent.wa_account_id, contactNumber, limit: agent.context_window_messages, + }); + if (!canLog || history.length === 0) return { skipped: 'nothing_to_do' }; + + const directive = '\n\n## Conversation ended — final logging\n- The customer has gone quiet and this conversation is over. Write the FINAL, COMPLETE record now using your logging tool(s): a full conversation summary and the correct final status. Prefer your sheet "upsert" tool (match the existing row by the customer\'s phone number) and/or your CRM tools. Do NOT write any reply to the customer — only call the tools. If everything is already logged and up to date, do nothing.'; + try { + const provider = getProvider(agent.ai_provider); + const result = await provider.runWithTools({ + systemPrompt: withContactContext(agent.system_prompt, contactNumber) + directive, + messages: [...history, { role: 'user', content: '(end of conversation — log the final summary now, do not reply)' }], + tools, + onToolCall: async ({ name, args }) => { + const exec = executors[name]; + if (!exec) throw new Error(`Unknown tool '${name}'`); + return await exec(args); + }, + onStep: async () => {}, + model: agent.llm_model, + apiKey, + maxIterations: Math.max(1, Math.min(20, agent.max_tool_iterations || 6)), + conversationKey: `${agent.id}:${contactNumber}:close`, + contactNumber, + agentId: agent.id, + }); + // We intentionally do NOT send result.finalText to the customer. + return { ok: true, outputTokens: result?.totalOutputTokens || 0 }; + } catch (e) { + console.error('[closeSummary] provider error:', e.message); + return { error: e.message }; + } +} + +module.exports = { runAgent, runAgentTest, buildToolsForAgent, buildMessageHistory, transcribeForAgent, runCloseSummary }; diff --git a/backend/src/index.js b/backend/src/index.js index 47d739e..f9dc541 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -28,10 +28,13 @@ const { publicRouter: googleIntegrationsPublicRouter, } = require('./routes/googleIntegrations'); const { router: agentsRouter } = require('./routes/agents'); +const { router: agentConversationRouter } = require('./routes/agentConversation'); const { router: aiModelsRouter } = require('./routes/aiModels'); const { router: eventsRouter } = require('./routes/events'); const { router: dashboardRouter } = require('./routes/dashboard'); const { router: pipelinesRouter } = require('./routes/pipelines'); +const { adminRouter: mcpAdminRouter, apiRouter: mcpApiRouter, ensureMcpTables } = require('./routes/mcp'); +const { mcpHttpHandler } = require('./mcpHttp'); const { startWorker: startMediaWorker, shutdown: shutdownMediaQueue } = require('./queue/mediaQueue'); const { startSendWorker, shutdownSendQueue } = require('./queue/sendQueue'); const { startAgentWorker, shutdownAgentQueue } = require('./queue/agentQueue'); @@ -124,6 +127,10 @@ app.use('/api', webhookRouter); // routes/googleIntegrations.js). Everything else under /google-integrations is // auth-required and mounted further down. app.use('/api', googleIntegrationsPublicRouter); +// MCP API — authenticates via its OWN bearer middleware (not the JWT cookie) +app.use('/api/mcp/v1', mcpApiRouter); +// Remote (Streamable HTTP) MCP connector — key in the URL path, public. +app.all('/api/mcp/http/:key', mcpHttpHandler); // Auth routes (public) app.use('/api', authRouter); @@ -142,6 +149,8 @@ app.use('/api', authMiddleware, mediaLibraryRouter); app.use('/api', authMiddleware, whatsappAccountsRouter); app.use('/api', authMiddleware, googleIntegrationsRouter); app.use('/api', authMiddleware, agentsRouter); +app.use('/api', authMiddleware, agentConversationRouter); +app.use('/api', authMiddleware, mcpAdminRouter); app.use('/api', authMiddleware, aiModelsRouter); app.use('/api', authMiddleware, eventsRouter); app.use('/api', authMiddleware, dashboardRouter); @@ -160,6 +169,9 @@ async function start() { // Apply any pending SQL migrations before touching the schema or serving. await require('./db/migrate').runMigrations(pool); await ensureTables(); + await ensureMcpTables().catch(err => + console.error('[mcp] table ensure failed (apply migration 057):', err.message) + ); mediaStorage.ensureBucket().catch(err => console.error('[media-storage] table ensure failed (will retry on first upload):', err.message) ); @@ -213,6 +225,16 @@ async function start() { } }, 30 * 60 * 1000).unref(); + // Agent close-summary sweeper: when an idle-summary agent's conversation goes + // quiet (no new message for its idle window) and no human has taken over, ask + // the agent to write its final summary to the sheet/CRM. Every 2 min. + const { sweepClosedConversations } = require('./services/agentCloseSummary'); + setInterval(() => { + sweepClosedConversations() + .then(n => { if (n > 0) console.log(`[closeSummary] summarised ${n} closed conversation(s)`); }) + .catch(err => console.error('[closeSummary] sweep error:', err.message)); + }, 2 * 60 * 1000).unref(); + // Template status auto-sync: Meta does NOT push template approval/rejection // status — we must poll. The tick fires every 10 min but only calls Meta while // at least one template is still awaiting review (status='SUBMITTED'). Once all diff --git a/backend/src/integrations/metaMedia.js b/backend/src/integrations/metaMedia.js index 165e0c5..f7d01fe 100644 --- a/backend/src/integrations/metaMedia.js +++ b/backend/src/integrations/metaMedia.js @@ -64,7 +64,7 @@ async function downloadMediaBinary(url, accessToken) { redirect: 'error', // no open-redirect pivoting to internal hosts headers: { Authorization: `Bearer ${token}`, - 'User-Agent': 'ForgeChat/1.0 (+https://github.com/Forgemind-git/ForgeChat)', + 'User-Agent': 'ForgeChat/1.0 (+https://github.com/Forgemind-git/Forge-Chat)', }, }); if (!res.ok) { diff --git a/backend/src/llm/anthropic.js b/backend/src/llm/anthropic.js index b514769..c6dc180 100644 --- a/backend/src/llm/anthropic.js +++ b/backend/src/llm/anthropic.js @@ -18,10 +18,12 @@ async function runWithTools({ }) { const client = new Anthropic({ apiKey }); - // Translate our generic messages to Anthropic's format. v1: text only. + // Translate our generic messages to Anthropic's format. `content` may be a + // plain string (text) OR an array of generic parts: { type:'text', text } and + // { type:'image', mime, data /* base64, no data: prefix */ }. const history = messages.map(m => ({ role: m.role, - content: [{ type: 'text', text: m.content }], + content: toAnthropicContent(m.content), })); let totalInputTokens = 0; @@ -112,6 +114,21 @@ async function runWithTools({ return { finalText, totalInputTokens, totalOutputTokens, iterations, capped: true }; } +// Generic content → Anthropic content blocks. A bare string becomes one text +// block; an array of {type:'text'|'image'} parts is mapped block-by-block. +function toAnthropicContent(content) { + if (typeof content === 'string') return [{ type: 'text', text: content }]; + if (Array.isArray(content)) { + return content.map(part => { + if (part?.type === 'image' && part.data) { + return { type: 'image', source: { type: 'base64', media_type: part.mime || 'image/jpeg', data: part.data } }; + } + return { type: 'text', text: String(part?.text ?? '') }; + }); + } + return [{ type: 'text', text: String(content ?? '') }]; +} + function safeParse(s) { if (typeof s !== 'string') return s; try { return JSON.parse(s); } catch { return { text: s.slice(0, 500) }; } diff --git a/backend/src/llm/openai.js b/backend/src/llm/openai.js index 1efb153..e43cee5 100644 --- a/backend/src/llm/openai.js +++ b/backend/src/llm/openai.js @@ -31,7 +31,7 @@ async function runWithTools({ const history = [ { role: 'system', content: systemPrompt }, - ...messages.map(m => ({ role: m.role, content: m.content })), + ...messages.map(m => ({ role: m.role, content: toOpenAIContent(m.content) })), ]; let totalInputTokens = 0; @@ -116,6 +116,22 @@ async function runWithTools({ return { finalText, totalInputTokens, totalOutputTokens, iterations, capped: true }; } +// Generic content → OpenAI content. A bare string passes through unchanged; an +// array of {type:'text'|'image'} parts becomes OpenAI's multimodal parts +// ({type:'text'} / {type:'image_url', image_url:{url:'data:;base64,...'}}). +function toOpenAIContent(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map(part => { + if (part?.type === 'image' && part.data) { + return { type: 'image_url', image_url: { url: `data:${part.mime || 'image/jpeg'};base64,${part.data}` } }; + } + return { type: 'text', text: String(part?.text ?? '') }; + }); + } + return String(content ?? ''); +} + function safeParse(s) { if (typeof s !== 'string') return s; try { return JSON.parse(s); } catch { return { text: s.slice(0, 500) }; } diff --git a/backend/src/mcpHttp.js b/backend/src/mcpHttp.js new file mode 100644 index 0000000..a9ae10b --- /dev/null +++ b/backend/src/mcpHttp.js @@ -0,0 +1,327 @@ +// Remote (Streamable HTTP) MCP transport, mounted at /api/mcp/http/:key. +// +// This is "Model B": anyone can connect with just a URL (key in the path) — +// no local files. Stateless + JSON responses (proxy-friendly through Traefik), +// a fresh McpServer per request. Tools call services/mcpService + agentService +// DIRECTLY (in-process), gated by the per-request key's capabilities. + +const { z } = require('zod'); +const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js'); +const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js'); +const agentService = require('./services/agentService'); +const mcpService = require('./services/mcpService'); + +function ok(data) { return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; } +function fail(msg) { return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true }; } + +// Wrap a tool: enforce capability, run, format result/error. +function gated(capabilities, name, fn) { + return async (args) => { + if (capabilities[name] !== true) return fail(`The '${name}' capability is disabled for MCP access.`); + try { return ok(await fn(args || {})); } + catch (err) { return fail(err.message || 'Tool failed'); } + }; +} + +const mediaGroupSchema = z.object({ + description: z.string().describe('REQUIRED. Tells the agent exactly WHEN to send this group — specific and action-oriented, e.g. "Send when the user confirms they want to enroll" or "Send after the user asks for pricing". Ask the user for this before finalising the group.'), + mediaIds: z.array(z.number()).optional().describe('Media library item ids to send.'), + links: z.array(z.string()).optional().describe('URLs to send as link messages.'), + templateId: z.number().nullable().optional().describe('Approved template id to fire. Always confirm the template content with the user via get_template before using this.'), +}).passthrough(); + +const GUIDE = `You are creating a ForgeChat WhatsApp AI agent. Gather and CONFIRM the full configuration with the user before calling create_agent. Offer real options fetched from their account — never guess ids, spreadsheets, tabs, models, or numbers. + +Walk this flow: +1. Ask what the agent should do (its purpose / goal). +2. Propose a clear name and a first draft of the system prompt; refine with the user. +3. Call list_wa_accounts and ask which WhatsApp number it should run on. +4. Call list_models and ask which AI model to use (show provider + model options). Pass the chosen aiModelId + llmModel. +5. Ask how it should trigger: "any" (every message) or "keyword". If keyword, ask for the keyword, match type (exact/contains/starts), case sensitivity, and session window minutes (default 30). + Also ask whether the agent should understand voice notes (set transcribeAudio:true) and/or images (set acceptImages:true). +6. Ask whether it needs tools. + - Google Sheets: + a. Call list_google_accounts and let the user pick which connected Google account to use. + b. Call search_spreadsheets (with that googleAccountId) and let the user pick a spreadsheet. + c. Call list_sheet_tabs (same googleAccountId) for that spreadsheet and let the user pick the tab. + c2. If the agent will LOG rows to the sheet (or you need the column layout), call read_sheet_values (range "A1:Z1") to read the real header row, then map the logged fields to those exact columns — don't assume column names. + d. Ask which operations to allow: read, append, update, upsert (one or more). For LOGGING a contact's data, prefer 'upsert' (updates the contact's existing row by a key column like phone, or adds one if new — no duplicates). Pass googleAccountId to add_google_sheets_tool. + - HTTP request (to call an external API / device / hardware / webhook): ask for the endpoint URL, method, any auth headers, and what inputs the AI should fill (each input's name, where it goes — path/query/body/header — type, and meaning). Then use add_http_tool. Use {name} in the URL for path inputs. +7. Ask whether it should send media bundles or templates (media groups) — optional. A media group is a bundle the agent sends at a specific moment. For EACH group: + a. Ask "when should this be sent?" — the answer becomes the group's description (e.g. "Send when the user confirms interest", "Send after the user asks for pricing"). This description is how the agent decides when to trigger the group, so make it specific and action-oriented. + b. Media: if the user mentions a file by name, call list_media with that name to resolve it to an id — never ask the user for an id. + c. Template: if the user mentions a template by name, call list_templates to find it, then call get_template to read its full content (body, header, buttons). Show the user the template name + body + buttons and ask them to confirm it is the right one BEFORE using its id. + d. Ask if there are more groups to add. Repeat until done. +8. Summarize the complete configuration and ask for explicit confirmation. +9. On confirmation, call create_agent. Then attach any chosen tools: add_google_sheets_tool for Sheets, add_http_tool for HTTP. +10. Report the created agent (id + recap) and offer to activate it or make edits. + +Notes: an ACTIVE agent needs both aiModelId and llmModel (otherwise save status:"draft"). Only one active agent per WhatsApp number. Always confirm destructive actions (delete) first.`; + +// Build a fresh server scoped to one request's capabilities. +function buildServer(capabilities) { + const server = new McpServer({ name: 'forgechat-agents', version: '1.0.0' }); + + /* discovery */ + server.registerTool('list_wa_accounts', { + title: 'List WhatsApp accounts', + description: 'List the WhatsApp business numbers an agent can run on. Use to ask the user which number to use. Returns id, displayName, phoneNumber, isActive, isDefault.', + inputSchema: {}, + }, gated(capabilities, 'discovery', () => mcpService.listWaAccounts())); + + server.registerTool('list_models', { + title: 'List AI models', + description: 'List connected AI model credentials and selectable model ids. Each entry has aiModelId, provider, providerLabel, label, and models[] of {value,label}. Pass aiModelId + a models[].value (as llmModel) to create_agent.', + inputSchema: {}, + }, gated(capabilities, 'discovery', () => mcpService.listModels())); + + server.registerTool('list_google_accounts', { + title: 'List Google accounts', + description: 'List the connected Google accounts. Use FIRST when configuring a Google Sheets tool so the user picks which account to read/write. Returns [{ id, label, status }]. Pass the chosen id as googleAccountId to search_spreadsheets / list_sheet_tabs / add_google_sheets_tool.', + inputSchema: {}, + }, gated(capabilities, 'discovery', () => mcpService.listGoogleAccounts())); + + server.registerTool('search_spreadsheets', { + title: 'Search Google spreadsheets', + description: 'Search a connected Google account for spreadsheets by name. Call list_google_accounts first to get googleAccountId. Returns { spreadsheets: [{ id, name }] }.', + inputSchema: { + googleAccountId: z.union([z.string(), z.number()]).describe('Google account id from list_google_accounts.'), + query: z.string().optional().describe('Optional search term.'), + }, + }, gated(capabilities, 'discovery', ({ googleAccountId, query }) => mcpService.searchSpreadsheets({ googleAccountId, q: query || '' }))); + + server.registerTool('list_sheet_tabs', { + title: 'List spreadsheet tabs', + description: 'List the tabs in a spreadsheet so the user can choose one. Returns { id, tabs: [...] }.', + inputSchema: { + googleAccountId: z.union([z.string(), z.number()]).describe('Google account id from list_google_accounts.'), + spreadsheetId: z.string().describe('Spreadsheet id from search_spreadsheets.'), + }, + }, gated(capabilities, 'discovery', ({ googleAccountId, spreadsheetId }) => mcpService.listSheetTabs(googleAccountId, spreadsheetId))); + + server.registerTool('read_sheet_values', { + title: 'Read spreadsheet cell values', + description: 'Read actual cell values from a tab — use this to see the real HEADER ROW and a few sample rows so you can map an agent\'s Sheets logging to the right columns. list_sheet_tabs only returns metadata (names/dimensions), NOT contents; this returns them. Returns { range, headers:[...], rows:[[...]], rowCount, truncated }. By default reads the whole tab from A1 (capped at maxRows). Pass an A1 range like "A1:Z1" to fetch only the header row.', + inputSchema: { + googleAccountId: z.union([z.string(), z.number()]).describe('Google account id from list_google_accounts.'), + spreadsheetId: z.string().describe('Spreadsheet id from search_spreadsheets.'), + tab: z.string().describe('Tab name from list_sheet_tabs.'), + range: z.string().optional().describe('Optional A1 range (e.g. "A1:Z1" for just headers, or "A1:Z20"). Omit to read the whole tab from A1.'), + maxRows: z.number().int().min(1).max(500).optional().describe('Soft cap on returned rows (default 50).'), + }, + }, gated(capabilities, 'discovery', ({ googleAccountId, spreadsheetId, tab, range, maxRows }) => mcpService.readSheetValues({ googleAccountId, spreadsheetId, tab, range, maxRows }))); + + server.registerTool('list_media', { + title: 'List media library items', + description: 'List media library items for media groups. Filter by type and/or name (partial, case-insensitive). Returns [{ id, name, mediaType, mimeType }]. When the user mentions a media name, call this with that name to resolve it to an id — then use that id in mediaGroups.', + inputSchema: { + type: z.enum(['image', 'video', 'audio', 'document']).optional(), + name: z.string().optional().describe('Partial name search (case-insensitive). Use when the user mentions a media file by name.'), + }, + }, gated(capabilities, 'discovery', ({ type, name }) => mcpService.listMedia(type, name))); + + server.registerTool('list_templates', { + title: 'List message templates', + description: 'List WhatsApp message templates (optionally by WhatsApp account). Returns [{ id, name, language, status, category, waAccountId }]. When the user mentions a template by name, call this to find it, then call get_template to read its full content before confirming with the user.', + inputSchema: { waAccountId: z.union([z.string(), z.number()]).optional() }, + }, gated(capabilities, 'discovery', ({ waAccountId }) => mcpService.listTemplates(waAccountId))); + + server.registerTool('get_template', { + title: 'Get template content', + description: 'Fetch the full content of a template — body text, header, footer, buttons, and variable samples. Call this after finding a template by name via list_templates, then show the content to the user (name + body + buttons) so they can confirm it is the right one before using its id in a media group or agent config.', + inputSchema: { id: z.union([z.string(), z.number()]).describe('Template id from list_templates.') }, + }, gated(capabilities, 'discovery', ({ id }) => mcpService.getTemplate(id))); + + server.registerTool('list_agents', { + title: 'List agents', + description: 'List all existing AI agents with tool counts and last-run time.', + inputSchema: {}, + }, gated(capabilities, 'discovery', () => agentService.listAgents())); + + server.registerTool('get_agent', { + title: 'Get agent', + description: 'Get one agent in full, including its tools[].', + inputSchema: { id: z.union([z.string(), z.number()]) }, + }, gated(capabilities, 'discovery', ({ id }) => agentService.getAgent(id))); + + /* mutations */ + server.registerTool('create_agent', { + title: 'Create agent', + description: + 'Create a new ForgeChat AI agent. Gather + CONFIRM all settings with the user first. For an ACTIVE agent pass aiModelId + llmModel; otherwise status:"draft". Only one active agent per WhatsApp number. After creating, use add_google_sheets_tool to attach a Sheets tool if wanted.', + inputSchema: { + name: z.string(), + systemPrompt: z.string(), + aiModelId: z.union([z.string(), z.number()]).optional(), + llmModel: z.string().optional(), + waAccountId: z.union([z.string(), z.number()]).optional(), + status: z.enum(['draft', 'active']).optional(), + isActive: z.boolean().optional(), + contextWindowMessages: z.number().int().min(1).max(100).optional(), + maxToolIterations: z.number().int().min(1).max(20).optional(), + transcribeAudio: z.boolean().optional(), + acceptImages: z.boolean().optional(), + triggerMode: z.enum(['any', 'keyword']).optional(), + triggerKeyword: z.string().optional(), + triggerMatchType: z.enum(['exact', 'contains', 'starts']).optional(), + triggerCaseSensitive: z.boolean().optional(), + triggerSessionMinutes: z.number().int().min(1).max(1440).optional(), + mediaGroups: z.array(mediaGroupSchema).optional(), + }, + }, gated(capabilities, 'create_agent', (a) => agentService.createAgent(a))); + + server.registerTool('update_agent', { + title: 'Update agent', + description: 'Update an agent. Only fields you pass change. Same validation as create_agent.', + inputSchema: { + id: z.union([z.string(), z.number()]), + name: z.string().optional(), + systemPrompt: z.string().optional(), + aiModelId: z.union([z.string(), z.number()]).nullable().optional(), + llmModel: z.string().nullable().optional(), + waAccountId: z.union([z.string(), z.number()]).nullable().optional(), + status: z.enum(['draft', 'active']).optional(), + isActive: z.boolean().optional(), + contextWindowMessages: z.number().int().min(1).max(100).optional(), + maxToolIterations: z.number().int().min(1).max(20).optional(), + transcribeAudio: z.boolean().optional(), + acceptImages: z.boolean().optional(), + triggerMode: z.enum(['any', 'keyword']).optional(), + triggerKeyword: z.string().optional(), + triggerMatchType: z.enum(['exact', 'contains', 'starts']).optional(), + triggerCaseSensitive: z.boolean().optional(), + triggerSessionMinutes: z.number().int().min(1).max(1440).optional(), + mediaGroups: z.array(mediaGroupSchema).optional(), + }, + }, gated(capabilities, 'update_agent', ({ id, ...patch }) => agentService.updateAgent(id, patch))); + + server.registerTool('add_google_sheets_tool', { + title: 'Add Google Sheets tool', + description: 'Attach a Google Sheets tool to an agent. Use list_google_accounts → search_spreadsheets → list_sheet_tabs first so the user picks a real account, spreadsheet + tab, and ask which ops to allow.', + inputSchema: { + agentId: z.union([z.string(), z.number()]), + googleAccountId: z.union([z.string(), z.number()]).describe('Google account id from list_google_accounts.'), + spreadsheetId: z.string(), + spreadsheetName: z.string().optional(), + sheetName: z.string(), + ops: z.array(z.enum(['read', 'append', 'update', 'upsert'])).min(1), + }, + }, gated(capabilities, 'manage_tools', ({ agentId, googleAccountId, spreadsheetId, spreadsheetName, sheetName, ops }) => + agentService.addTool(agentId, { + toolType: 'google_sheets', + config: { google_account_id: googleAccountId, spreadsheet_id: spreadsheetId, spreadsheet_name: spreadsheetName || null, sheet_name: sheetName, ops }, + }))); + + server.registerTool('add_http_tool', { + title: 'Add HTTP request tool', + description: + 'Attach an HTTP-request tool so the agent can call an external system (device/hardware API, webhook, internal service) during a chat. ' + + 'You set a fixed method + URL + static headers (for auth); the agent\'s AI fills the declared params at call time. ' + + 'Path params replace {name} in the URL, query params append to the URL, body params build the JSON body, header params become request headers. ' + + 'Confirm the endpoint + params with the user before adding.', + inputSchema: { + agentId: z.union([z.string(), z.number()]), + label: z.string().describe('Short action name, e.g. "Turn on smart light".'), + description: z.string().describe('When the AI should call this tool — the model reads this to decide. Be specific.'), + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method.'), + url: z.string().describe('Endpoint URL. Use {name} to insert a path parameter, e.g. https://api.io/devices/{device_id}/state'), + headers: z.array(z.object({ k: z.string(), v: z.string() })).optional().describe('Static headers sent on every call (auth tokens etc.).'), + params: z.array(z.object({ + name: z.string().describe('Identifier (letters/numbers/underscore).'), + in: z.enum(['path', 'query', 'body', 'header']).describe('Where the value goes.'), + type: z.enum(['string', 'number', 'boolean']).optional(), + description: z.string().optional().describe('What the value means — the AI reads this.'), + required: z.boolean().optional(), + })).optional().describe('Values the AI fills when calling the tool.'), + timeoutMs: z.number().int().min(1000).max(30000).optional(), + }, + }, gated(capabilities, 'manage_tools', ({ agentId, label, description, method, url, headers, params, timeoutMs }) => + agentService.addTool(agentId, { + toolType: 'http_request', + config: { label, description, method, url, headers: headers || [], params: params || [], timeout_ms: timeoutMs || 10000 }, + }))); + + server.registerTool('add_tool', { + title: 'Add tool (generic)', + description: 'Attach a tool by raw toolType + config. Prefer add_google_sheets_tool for Sheets and add_http_tool for HTTP. Supported toolTypes: "google_sheets", "http_request".', + inputSchema: { + agentId: z.union([z.string(), z.number()]), + toolType: z.string(), + config: z.record(z.any()), + isEnabled: z.boolean().optional(), + }, + }, gated(capabilities, 'manage_tools', ({ agentId, toolType, config, isEnabled }) => + agentService.addTool(agentId, { toolType, config, isEnabled }))); + + server.registerTool('update_tool', { + title: 'Update tool', + description: "Update an agent tool's config or enabled flag.", + inputSchema: { + agentId: z.union([z.string(), z.number()]), + toolId: z.union([z.string(), z.number()]), + config: z.record(z.any()).optional(), + isEnabled: z.boolean().optional(), + }, + }, gated(capabilities, 'manage_tools', ({ agentId, toolId, config, isEnabled }) => + agentService.updateTool(agentId, toolId, { config, isEnabled }))); + + server.registerTool('delete_tool', { + title: 'Delete tool', + description: 'Remove a tool from an agent. Confirm with the user first.', + inputSchema: { agentId: z.union([z.string(), z.number()]), toolId: z.union([z.string(), z.number()]) }, + }, gated(capabilities, 'delete', ({ agentId, toolId }) => agentService.deleteTool(agentId, toolId))); + + server.registerTool('delete_agent', { + title: 'Delete agent', + description: 'Delete an agent entirely. Destructive — confirm with the user first.', + inputSchema: { id: z.union([z.string(), z.number()]) }, + }, gated(capabilities, 'delete', ({ id }) => agentService.deleteAgent(id))); + + /* prompt */ + server.registerPrompt('create-forgechat-agent', { + title: 'Create a ForgeChat agent', + description: 'Guided flow to create and configure a ForgeChat WhatsApp AI agent.', + argsSchema: {}, + }, () => ({ messages: [{ role: 'user', content: { type: 'text', text: GUIDE } }] })); + + return server; +} + +// Express handler. Stateless: new server+transport per POST. +async function mcpHttpHandler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed. This is a stateless MCP server — use POST.' }, + id: null, + }); + } + let capabilities; + try { + ({ capabilities } = await mcpService.validateKey(req.params.key)); + } catch (err) { + return res.status(err.status || 401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: err.message || 'Unauthorized' }, + id: null, + }); + } + + const server = buildServer(capabilities); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + res.on('close', () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (err) { + console.error('[mcpHttp] error:', err.message); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null }); + } + } +} + +module.exports = { mcpHttpHandler }; diff --git a/backend/src/permissions.js b/backend/src/permissions.js index f20a1bd..1fb518c 100644 --- a/backend/src/permissions.js +++ b/backend/src/permissions.js @@ -17,7 +17,7 @@ const PAGES = [ 'chatbot-builder', 'media-library', 'about', 'admin-settings:general', 'admin-settings:tags', 'admin-settings:category', 'admin-settings:fields', 'admin-settings:whatsapp-accounts', - 'admin-settings:users', + 'admin-settings:users', 'admin-settings:mcp', ]; const ROLE_PAGE_DEFAULTS = { diff --git a/backend/src/routes/agentConversation.js b/backend/src/routes/agentConversation.js new file mode 100644 index 0000000..39e831d --- /dev/null +++ b/backend/src/routes/agentConversation.js @@ -0,0 +1,90 @@ +// Per-conversation agent controls for the Chats UI: is the bot active here, and +// the manual "take over" / "return to bot" switch. BDA-accessible (gated by +// assertContactAccess), unlike the admin-only agent CRUD in routes/agents.js. + +const { Router } = require('express'); +const pool = require('../db'); +const { assertContactAccess } = require('../middleware/access'); +const { performHandoff, resumeAgent } = require('../services/agentHandoff'); + +const router = Router(); + +// Find the active agent (if any) bound to the WhatsApp number of a contact. +async function activeAgentForWaNumber(waNumber) { + const { rows } = await pool.query( + `SELECT a.id, a.name + FROM coexistence.agents a + JOIN coexistence.whatsapp_accounts w ON w.id = a.wa_account_id + WHERE a.is_active = TRUE + AND regexp_replace(w.display_phone_number, '\\D', '', 'g') = regexp_replace($1, '\\D', '', 'g') + LIMIT 1`, + [waNumber || ''], + ); + return rows[0] || null; +} + +// GET /api/agent-conversation?waNumber=&contactNumber= +// → { hasAgent, agentId, agentName, paused, pausedBy, pausedReason, pausedAt } +router.get('/agent-conversation', async (req, res) => { + const waNumber = String(req.query.waNumber || ''); + const contactNumber = String(req.query.contactNumber || ''); + if (!waNumber || !contactNumber) return res.status(400).json({ error: 'waNumber and contactNumber are required' }); + if (!(await assertContactAccess(req, res, waNumber, contactNumber))) return; + try { + const agent = await activeAgentForWaNumber(waNumber); + const { rows } = await pool.query( + `SELECT agent_paused, agent_paused_by, agent_paused_reason, agent_paused_at + FROM coexistence.contacts WHERE wa_number = $1 AND contact_number = $2`, + [waNumber, contactNumber], + ); + const c = rows[0] || {}; + res.json({ + hasAgent: !!agent, + agentId: agent?.id || null, + agentName: agent?.name || null, + paused: !!c.agent_paused, + pausedBy: c.agent_paused_by || null, + pausedReason: c.agent_paused_reason || null, + pausedAt: c.agent_paused_at || null, + }); + } catch (err) { + console.error('[agent-conversation] status error:', err.message); + res.status(500).json({ error: 'Failed to load agent status' }); + } +}); + +// POST /api/agent-conversation/pause { waNumber, contactNumber } — take over. +router.post('/agent-conversation/pause', async (req, res) => { + const { waNumber, contactNumber } = req.body || {}; + if (!waNumber || !contactNumber) return res.status(400).json({ error: 'waNumber and contactNumber are required' }); + if (!(await assertContactAccess(req, res, waNumber, contactNumber))) return; + try { + const agent = await activeAgentForWaNumber(waNumber); + if (!agent) return res.status(400).json({ error: 'No active AI agent on this number.' }); + const who = req.user?.displayName || req.user?.username || `user ${req.user?.id}`; + const r = await performHandoff({ + agentId: agent.id, handoffUserIds: [], waNumber, contactNumber, + reason: `Taken over by ${who}`, by: `manual:${req.user?.id}`, assignTo: req.user?.id, + }); + res.json({ ok: true, paused: true, assignedUserId: r.assignedUserId, assignedUserName: r.assignedUserName }); + } catch (err) { + console.error('[agent-conversation] pause error:', err.message); + res.status(500).json({ error: 'Failed to take over the conversation' }); + } +}); + +// POST /api/agent-conversation/resume { waNumber, contactNumber } — return to bot. +router.post('/agent-conversation/resume', async (req, res) => { + const { waNumber, contactNumber } = req.body || {}; + if (!waNumber || !contactNumber) return res.status(400).json({ error: 'waNumber and contactNumber are required' }); + if (!(await assertContactAccess(req, res, waNumber, contactNumber))) return; + try { + await resumeAgent({ waNumber, contactNumber, by: `manual:${req.user?.id}` }); + res.json({ ok: true, paused: false }); + } catch (err) { + console.error('[agent-conversation] resume error:', err.message); + res.status(500).json({ error: 'Failed to return the conversation to the bot' }); + } +}); + +module.exports = { router }; diff --git a/backend/src/routes/agents.js b/backend/src/routes/agents.js index c637c9d..c99d5d8 100644 --- a/backend/src/routes/agents.js +++ b/backend/src/routes/agents.js @@ -36,6 +36,7 @@ function agentShape(row) { contextWindowMessages: row.context_window_messages, maxToolIterations: row.max_tool_iterations, transcribeAudio: !!row.transcribe_audio, + acceptImages: !!row.accept_images, triggerMode: row.trigger_mode || 'any', triggerKeyword: row.trigger_keyword || '', triggerMatchType: row.trigger_match_type || 'contains', @@ -121,6 +122,79 @@ function toolShape(row) { }; } +/* ----------------------- tool config validation ---------------------- */ + +const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); +const PARAM_LOCATIONS = new Set(['path', 'query', 'body', 'header']); +const PARAM_TYPES = new Set(['string', 'number', 'boolean']); + +class ToolError extends Error {} // thrown → 400 + +// Validate + normalise an http_request tool config. The admin owns method/url/ +// static headers; the agent's LLM only fills the declared params at call time. +function validateHttpConfig(cfg = {}) { + const label = String(cfg.label || '').trim(); + if (!label) throw new ToolError('Give the HTTP tool a name (label).'); + const description = String(cfg.description || '').trim(); + if (!description) throw new ToolError('Describe when the agent should use this HTTP tool — the AI needs it to decide.'); + + const method = String(cfg.method || 'GET').trim().toUpperCase(); + if (!HTTP_METHODS.has(method)) throw new ToolError(`Method must be one of ${[...HTTP_METHODS].join(', ')}.`); + + const url = normalizeUrl(cfg.url); + if (!url) throw new ToolError('Enter a valid http(s) URL for the HTTP tool.'); + + const headers = Array.isArray(cfg.headers) + ? cfg.headers.map(h => ({ k: String(h?.k || '').trim(), v: String(h?.v ?? '').trim() })).filter(h => h.k).slice(0, 30) + : []; + + const seen = new Set(); + const params = Array.isArray(cfg.params) + ? cfg.params.map((p, idx) => { + const name = String(p?.name || '').trim(); + if (!name) throw new ToolError(`Parameter #${idx + 1} needs a name.`); + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) throw new ToolError(`Parameter "${name}" must be a simple identifier (letters, numbers, underscore; no leading digit).`); + if (seen.has(name)) throw new ToolError(`Duplicate parameter name "${name}".`); + seen.add(name); + return { + name, + in: PARAM_LOCATIONS.has(p?.in) ? p.in : 'body', + type: PARAM_TYPES.has(p?.type) ? p.type : 'string', + description: String(p?.description || '').trim().slice(0, 500), + required: !!p?.required, + }; + }).slice(0, 30) + : []; + + for (const ph of [...url.matchAll(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g)].map(m => m[1])) { + const p = params.find(x => x.name === ph); + if (!p) throw new ToolError(`URL placeholder {${ph}} has no matching parameter — add a "path" parameter named "${ph}".`); + if (p.in !== 'path') throw new ToolError(`Parameter "${ph}" is used in the URL path, so its location must be "path".`); + } + + const timeoutMs = Math.max(1000, Math.min(30000, parseInt(cfg.timeout_ms || 10000, 10) || 10000)); + return { label: label.slice(0, 120), description: description.slice(0, 1000), method, url, headers, params, timeout_ms: timeoutMs }; +} + +// Validate a tool body by type; returns the cleaned config to persist. Throws +// ToolError (→ 400) on invalid input. +function validateToolConfig(toolType, config) { + if (toolType === 'google_sheets') { + const cfg = config; + if (!cfg.google_account_id || !cfg.spreadsheet_id || !cfg.sheet_name) { + throw new ToolError('Sheets tool needs google_account_id, spreadsheet_id, sheet_name'); + } + if (!Array.isArray(cfg.ops) || cfg.ops.length === 0) { + throw new ToolError('Sheets tool needs at least one op enabled (read/append/update)'); + } + return cfg; + } + if (toolType === 'http_request') { + return validateHttpConfig(config); + } + return config; +} + router.get('/agents', async (req, res) => { try { const { rows } = await pool.query( @@ -213,8 +287,8 @@ router.post('/agents', adminOnly, async (req, res) => { context_window_messages, max_tool_iterations, trigger_mode, trigger_keyword, trigger_match_type, trigger_case_sensitive, trigger_session_minutes, media_groups, - transcribe_audio) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + transcribe_audio, accept_images) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) RETURNING id`, [ b.name.trim(), b.description?.trim() || null, @@ -227,6 +301,7 @@ router.post('/agents', adminOnly, async (req, res) => { Math.max(1, Math.min(1440, parseInt(b.triggerSessionMinutes || 30, 10))), JSON.stringify(mediaGroups), !!b.transcribeAudio, + !!b.acceptImages, ], ); res.status(201).json(await fetchAgent(rows[0].id)); @@ -295,6 +370,7 @@ router.put('/agents/:id', adminOnly, async (req, res) => { push('context_window_messages', Math.max(1, Math.min(100, parseInt(b.contextWindowMessages, 10) || 20))); } if (b.transcribeAudio !== undefined) push('transcribe_audio', !!b.transcribeAudio); + if (b.acceptImages !== undefined) push('accept_images', !!b.acceptImages); if (b.maxToolIterations !== undefined) { push('max_tool_iterations', Math.max(1, Math.min(20, parseInt(b.maxToolIterations, 10) || 6))); } @@ -336,6 +412,154 @@ router.delete('/agents/:id', adminOnly, async (req, res) => { } }); +/* --------------------------- Export / Import -------------------------- */ + +// Resolve an exported model reference to a local ai_models id: exact id first, +// then provider+label, then provider alone. Returns null when nothing matches. +async function resolveModelId({ aiModelId, aiProvider, aiModelLabel }) { + if (aiModelId) { + const m = await getAiModel(aiModelId); + if (m) return aiModelId; + } + if (aiProvider) { + const { rows } = await pool.query( + `SELECT id FROM coexistence.ai_models WHERE provider = $1 AND ($2::text IS NULL OR label = $2) ORDER BY id LIMIT 1`, + [aiProvider, aiModelLabel || null], + ); + if (rows[0]) return rows[0].id; + const { rows: any } = await pool.query( + `SELECT id FROM coexistence.ai_models WHERE provider = $1 ORDER BY id LIMIT 1`, + [aiProvider], + ); + if (any[0]) return any[0].id; + } + return null; +} + +// GET /agents/:id/export — portable JSON (admin-only; the file can carry tool +// secrets like HTTP auth headers). +router.get('/agents/:id/export', adminOnly, async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT a.*, am.provider AS ai_provider, am.label AS ai_label + FROM coexistence.agents a + LEFT JOIN coexistence.ai_models am ON am.id = a.ai_model_id + WHERE a.id = $1`, + [req.params.id], + ); + if (rows.length === 0) return res.status(404).json({ error: 'Not found' }); + const { rows: tools } = await pool.query( + `SELECT * FROM coexistence.agent_tools WHERE agent_id = $1 ORDER BY id`, + [req.params.id], + ); + const full = agentShape(rows[0]); + res.json({ + type: 'forgechat.agent', + version: 1, + agent: { + name: full.name, + description: full.description, + systemPrompt: full.systemPrompt, + llmModel: full.llmModel, + aiProvider: full.aiProvider, + aiModelLabel: full.aiModelLabel, + aiModelId: full.aiModelId, + waAccountId: full.waAccountId, + contextWindowMessages: full.contextWindowMessages, + maxToolIterations: full.maxToolIterations, + transcribeAudio: full.transcribeAudio, + acceptImages: full.acceptImages, + triggerMode: full.triggerMode, + triggerKeyword: full.triggerKeyword, + triggerMatchType: full.triggerMatchType, + triggerCaseSensitive: full.triggerCaseSensitive, + triggerSessionMinutes: full.triggerSessionMinutes, + mediaGroups: full.mediaGroups, + }, + tools: tools.map(t => ({ toolType: t.tool_type, config: t.config || {}, isEnabled: t.is_enabled })), + }); + } catch (err) { + console.error('[agents] export error:', err.message); + res.status(500).json({ error: 'Failed to export agent' }); + } +}); + +// POST /agents/import — create a NEW draft agent from an export file (admin-only). +// Relinks model/number when they resolve here (else clears + warns); re-adds +// every tool (skipping any that fail validation). +router.post('/agents/import', adminOnly, async (req, res) => { + try { + const payload = req.body || {}; + if (payload.type !== 'forgechat.agent' || !payload.agent) { + return res.status(400).json({ error: 'That file is not a ForgeChat agent export.' }); + } + const a = payload.agent; + if (!a.name || !a.systemPrompt) { + return res.status(400).json({ error: 'The export file is missing required agent fields (name / system prompt).' }); + } + + const aiModelId = await resolveModelId(a); + const llmModel = aiModelId ? (a.llmModel || null) : null; + let waAccountId = null; + if (a.waAccountId) { + const { rows } = await pool.query('SELECT id FROM coexistence.whatsapp_accounts WHERE id = $1', [a.waAccountId]); + if (rows[0]) waAccountId = a.waAccountId; + } + + const mediaGroups = normalizeMediaGroups(a.mediaGroups); + const { rows: ins } = await pool.query( + `INSERT INTO coexistence.agents + (name, description, system_prompt, ai_model_id, llm_model, + status, wa_account_id, is_active, + context_window_messages, max_tool_iterations, + trigger_mode, trigger_keyword, trigger_match_type, + trigger_case_sensitive, trigger_session_minutes, media_groups, + transcribe_audio, accept_images) + VALUES ($1,$2,$3,$4,$5,'draft',$6,false,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + RETURNING id`, + [ + `${a.name} (imported)`.slice(0, 200), a.description?.trim() || null, + a.systemPrompt, aiModelId, llmModel, + waAccountId, + Math.max(1, Math.min(100, parseInt(a.contextWindowMessages || 20, 10))), + Math.max(1, Math.min(20, parseInt(a.maxToolIterations || 6, 10))), + (a.triggerMode === 'keyword' ? 'keyword' : 'any'), + (typeof a.triggerKeyword === 'string' ? a.triggerKeyword.trim().slice(0, 200) : null) || null, + cleanMatchType(a.triggerMatchType), + !!a.triggerCaseSensitive, + Math.max(1, Math.min(1440, parseInt(a.triggerSessionMinutes || 30, 10))), + JSON.stringify(mediaGroups), + !!a.transcribeAudio, + !!a.acceptImages, + ], + ); + const newId = ins[0].id; + + const warnings = []; + if (a.aiModelId && !aiModelId) warnings.push('The AI model from the file was not found here — pick a model before going live.'); + if (a.waAccountId && !waAccountId) warnings.push('The WhatsApp number from the file was not found here — pick a number before going live.'); + + for (const t of (Array.isArray(payload.tools) ? payload.tools : [])) { + try { + const cleanConfig = validateToolConfig(t.toolType, t.config || {}); + await pool.query( + `INSERT INTO coexistence.agent_tools (agent_id, tool_type, config, is_enabled) VALUES ($1,$2,$3,$4)`, + [newId, t.toolType, JSON.stringify(cleanConfig), t.isEnabled !== false], + ); + } catch (e) { + warnings.push(`Skipped a ${t.toolType || 'tool'}: ${e.message}`); + } + } + + const full = await fetchAgent(newId); + const { rows: tools } = await pool.query(`SELECT * FROM coexistence.agent_tools WHERE agent_id = $1 ORDER BY id`, [newId]); + res.status(201).json({ agent: { ...full, tools: tools.map(toolShape) }, warnings }); + } catch (err) { + console.error('[agents] import error:', err.message); + res.status(500).json({ error: 'Failed to import agent' }); + } +}); + /* --------------------------- Tools (nested) --------------------------- */ router.post('/agents/:id/tools', adminOnly, async (req, res) => { @@ -344,19 +568,13 @@ router.post('/agents/:id/tools', adminOnly, async (req, res) => { if (!b.toolType || !b.config) { return res.status(400).json({ error: 'toolType and config are required' }); } - if (b.toolType === 'google_sheets') { - const cfg = b.config; - if (!cfg.google_account_id || !cfg.spreadsheet_id || !cfg.sheet_name) { - return res.status(400).json({ error: 'Sheets tool needs google_account_id, spreadsheet_id, sheet_name' }); - } - if (!Array.isArray(cfg.ops) || cfg.ops.length === 0) { - return res.status(400).json({ error: 'Sheets tool needs at least one op enabled (read/append/update)' }); - } - } + let cleanConfig; + try { cleanConfig = validateToolConfig(b.toolType, b.config); } + catch (e) { if (e instanceof ToolError) return res.status(400).json({ error: e.message }); throw e; } const { rows } = await pool.query( `INSERT INTO coexistence.agent_tools (agent_id, tool_type, config, is_enabled) VALUES ($1,$2,$3,$4) RETURNING *`, - [req.params.id, b.toolType, JSON.stringify(b.config), b.isEnabled !== false], + [req.params.id, b.toolType, JSON.stringify(cleanConfig), b.isEnabled !== false], ); res.status(201).json(toolShape(rows[0])); } catch (err) { @@ -371,7 +589,18 @@ router.put('/agents/:id/tools/:toolId', adminOnly, async (req, res) => { const sets = []; const params = []; let i = 1; - if (b.config !== undefined) { sets.push(`config = $${i++}`); params.push(JSON.stringify(b.config)); } + if (b.config !== undefined) { + // Re-validate against the existing tool's type so an edit can't store junk. + const { rows: cur } = await pool.query( + 'SELECT tool_type FROM coexistence.agent_tools WHERE agent_id = $1 AND id = $2', + [req.params.id, req.params.toolId], + ); + if (cur.length === 0) return res.status(404).json({ error: 'Not found' }); + let cleanConfig; + try { cleanConfig = validateToolConfig(cur[0].tool_type, b.config); } + catch (e) { if (e instanceof ToolError) return res.status(400).json({ error: e.message }); throw e; } + sets.push(`config = $${i++}`); params.push(JSON.stringify(cleanConfig)); + } if (b.isEnabled !== undefined) { sets.push(`is_enabled = $${i++}`); params.push(!!b.isEnabled); } if (sets.length === 0) return res.status(400).json({ error: 'No updatable fields provided' }); params.push(req.params.id, req.params.toolId); diff --git a/backend/src/routes/chatbots.js b/backend/src/routes/chatbots.js index 5844e6c..43deda8 100644 --- a/backend/src/routes/chatbots.js +++ b/backend/src/routes/chatbots.js @@ -155,6 +155,48 @@ router.post('/chatbots/:id/duplicate', requirePermission('chatbot-builder'), asy } }); +// GET /chatbots/:id/export — portable automation file (id/timestamps stripped). +router.get('/chatbots/:id/export', requirePermission('chatbot-builder'), async (req, res) => { + try { + const { rows } = await pool.query( + 'SELECT name, description, trigger_type, config FROM coexistence.chatbots WHERE id = $1', + [req.params.id] + ); + if (rows.length === 0) return res.status(404).json({ error: 'Chatbot not found' }); + const c = rows[0]; + res.json({ + type: 'forgechat.automation', + version: 1, + automation: { name: c.name, description: c.description, trigger_type: c.trigger_type, config: c.config || {} }, + }); + } catch (err) { + console.error('[chatbots] export error:', err.message); + res.status(500).json({ error: 'Failed to export chatbot' }); + } +}); + +// POST /chatbots/import — create a new automation from an export file. Always +// lands DISABLED ('inactive') so it can't fire until reviewed/enabled. +router.post('/chatbots/import', requirePermission('chatbot-builder'), async (req, res) => { + try { + const payload = req.body || {}; + if (payload.type !== 'forgechat.automation' || !payload.automation || !payload.automation.name) { + return res.status(400).json({ error: 'That file is not a ForgeChat automation export.' }); + } + const a = payload.automation; + const { rows } = await pool.query( + `INSERT INTO coexistence.chatbots (name, description, status, trigger_type, config) + VALUES ($1,$2,'inactive',$3,$4) + RETURNING id, name, description, status, trigger_type, config, created_at, updated_at`, + [`${String(a.name).trim()} (imported)`.slice(0, 200), a.description || null, a.trigger_type || 'keyword', JSON.stringify(sanitizeToLinear(a.config) || {})] + ); + res.status(201).json(rows[0]); + } catch (err) { + console.error('[chatbots] import error:', err.message); + res.status(500).json({ error: 'Failed to import chatbot' }); + } +}); + // DELETE /chatbots/:id router.delete('/chatbots/:id', requirePermission('chatbot-builder'), async (req, res) => { try { diff --git a/backend/src/routes/mcp.js b/backend/src/routes/mcp.js new file mode 100644 index 0000000..f477059 --- /dev/null +++ b/backend/src/routes/mcp.js @@ -0,0 +1,272 @@ +// External MCP access — admin management + REST API for the stdio MCP server. +// +// adminRouter (mounted under authMiddleware, every route adminOnly) +// /mcp/settings GET|PUT — master switch + capability toggles +// /mcp/keys GET|POST|PUT|DELETE — bearer API keys (plaintext shown once) +// /mcp/install GET — connection details for the UI install panel +// +// apiRouter (mounted on /api/mcp/v1, OWN bearer middleware — header auth) +// discovery + agent CRUD consumed by the local (stdio) MCP server. +// +// The REMOTE (Streamable HTTP) transport lives in ../mcpHttp.js and shares the +// same key-validation + discovery via services/mcpService.js. + +const { Router } = require('express'); +const crypto = require('crypto'); +const pool = require('../db'); +const { adminOnly } = require('../middleware/access'); +const { hashApiKey } = require('../util/crypto'); +const agentService = require('../services/agentService'); +const mcpService = require('../services/mcpService'); + +const { CAPABILITY_KEYS, ensureMcpTables, loadSettings } = mcpService; + +/* ============================ admin router ============================ */ + +const adminRouter = Router(); + +adminRouter.get('/mcp/settings', adminOnly, async (req, res) => { + try { + res.json(await loadSettings()); + } catch (err) { + console.error('[mcp] settings get error:', err.message); + res.status(500).json({ error: 'Failed to load MCP settings' }); + } +}); + +adminRouter.put('/mcp/settings', adminOnly, async (req, res) => { + try { + const b = req.body || {}; + const cur = await loadSettings(); + const masterEnabled = b.masterEnabled !== undefined ? !!b.masterEnabled : cur.masterEnabled; + const caps = { ...cur.capabilities }; + if (b.capabilities && typeof b.capabilities === 'object') { + for (const k of CAPABILITY_KEYS) { + if (b.capabilities[k] !== undefined) caps[k] = !!b.capabilities[k]; + } + } + await pool.query( + `UPDATE coexistence.mcp_settings + SET master_enabled = $1, capabilities = $2, updated_at = NOW() + WHERE id = 1`, + [masterEnabled, JSON.stringify(caps)], + ); + res.json(await loadSettings()); + } catch (err) { + console.error('[mcp] settings put error:', err.message); + res.status(500).json({ error: 'Failed to update MCP settings' }); + } +}); + +function keyShape(r) { + return { + id: r.id, + label: r.label, + keyPrefix: r.key_prefix, + keyLast4: r.key_last4, + isEnabled: r.is_enabled, + lastUsedAt: r.last_used_at, + createdAt: r.created_at, + }; +} + +adminRouter.get('/mcp/keys', adminOnly, async (req, res) => { + try { + const { rows } = await pool.query('SELECT * FROM coexistence.mcp_api_keys ORDER BY created_at DESC'); + res.json(rows.map(keyShape)); + } catch (err) { + console.error('[mcp] keys list error:', err.message); + res.status(500).json({ error: 'Failed to list keys' }); + } +}); + +adminRouter.post('/mcp/keys', adminOnly, async (req, res) => { + try { + const label = String(req.body?.label || '').trim(); + if (!label) return res.status(400).json({ error: 'A label is required' }); + const plain = 'fck_live_' + crypto.randomBytes(24).toString('base64url'); + const keyPrefix = plain.slice(0, 13); + const keyLast4 = plain.slice(-4); + const { rows } = await pool.query( + `INSERT INTO coexistence.mcp_api_keys (label, key_prefix, key_last4, key_hash, created_by) + VALUES ($1,$2,$3,$4,$5) RETURNING *`, + [label, keyPrefix, keyLast4, hashApiKey(plain), req.user?.id || null], + ); + res.status(201).json({ ...keyShape(rows[0]), key: plain }); + } catch (err) { + console.error('[mcp] key create error:', err.message); + res.status(500).json({ error: 'Failed to create key' }); + } +}); + +adminRouter.put('/mcp/keys/:id', adminOnly, async (req, res) => { + try { + const b = req.body || {}; + const sets = []; + const params = []; + let i = 1; + if (b.label !== undefined) { sets.push(`label = $${i++}`); params.push(String(b.label).trim()); } + if (b.isEnabled !== undefined) { sets.push(`is_enabled = $${i++}`); params.push(!!b.isEnabled); } + if (sets.length === 0) return res.status(400).json({ error: 'No updatable fields provided' }); + params.push(req.params.id); + const { rows } = await pool.query( + `UPDATE coexistence.mcp_api_keys SET ${sets.join(', ')} WHERE id = $${i} RETURNING *`, + params, + ); + if (rows.length === 0) return res.status(404).json({ error: 'Not found' }); + res.json(keyShape(rows[0])); + } catch (err) { + console.error('[mcp] key update error:', err.message); + res.status(500).json({ error: 'Failed to update key' }); + } +}); + +adminRouter.delete('/mcp/keys/:id', adminOnly, async (req, res) => { + try { + const { rowCount } = await pool.query('DELETE FROM coexistence.mcp_api_keys WHERE id = $1', [req.params.id]); + if (rowCount === 0) return res.status(404).json({ error: 'Not found' }); + res.json({ ok: true }); + } catch (err) { + console.error('[mcp] key delete error:', err.message); + res.status(500).json({ error: 'Failed to delete key' }); + } +}); + +adminRouter.get('/mcp/install', adminOnly, (req, res) => { + const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'https').split(',')[0]; + const host = req.headers['x-forwarded-host'] || req.headers.host || (process.env.FORGECRM_DOMAIN || ''); + const base = `${proto}://${host}`; + const apiUrl = `${base}/api/mcp/v1`; + const remoteUrl = `${base}/api/mcp/http/`; + const serverPath = process.env.MCP_SERVER_PATH || '/root/Forge-Chat/mcp-server/src/index.js'; + res.json({ + // Remote (hosted) connector — paste this URL (with a real key) into Claude's + // "Add custom connector" dialog or any MCP client. No local files needed. + remoteUrl, + // Local (stdio) connector — for the node server run from a config file. + apiUrl, + serverPath, + configSnippet: { + mcpServers: { + 'forgechat-agents': { + command: 'node', + args: [serverPath], + env: { FORGECHAT_API_URL: apiUrl, FORGECHAT_API_KEY: 'fck_live_PASTE_YOUR_KEY' }, + }, + }, + }, + }); +}); + +/* ============================= api router ============================ */ + +const apiRouter = Router(); + +// Bearer auth (header) + capability gating for every /api/mcp/v1 request. +async function mcpKeyAuth(req, res, next) { + try { + const hdr = req.headers.authorization || ''; + const m = hdr.match(/^Bearer\s+(.+)$/i); + if (!m) return res.status(401).json({ error: 'Missing bearer token' }); + const { capabilities, keyId } = await mcpService.validateKey(m[1]); + req.mcp = { capabilities }; + req.user = { id: keyId, role: 'admin', viaMcp: true }; + next(); + } catch (err) { + res.status(err.status || 500).json({ error: err.message || 'Authentication failed' }); + } +} + +function requireCap(name) { + return (req, res, next) => { + if (req.mcp?.capabilities?.[name] !== true) { + return res.status(403).json({ error: `The '${name}' capability is disabled for MCP access.` }); + } + next(); + }; +} + +apiRouter.use(mcpKeyAuth); + +function sendErr(res, err, fallback) { + if (err instanceof agentService.ApiError) { + return res.status(err.status).json({ error: err.message }); + } + console.error(`[mcp] ${fallback}:`, err.message); + return res.status(500).json({ error: fallback }); +} + +// Surface "Google not connected" discovery errors as 400 instead of 500. +async function discovery(res, fn, fallback) { + try { + res.json(await fn()); + } catch (err) { + const msg = err?.message || fallback; + if (/connect|token|credential|integration|auth/i.test(msg)) { + return res.status(400).json({ error: msg }); + } + console.error(`[mcp] ${fallback}:`, msg); + res.status(500).json({ error: fallback }); + } +} + +/* --------- discovery --------- */ +apiRouter.get('/wa-accounts', requireCap('discovery'), (req, res) => + discovery(res, () => mcpService.listWaAccounts(), 'Failed to list WhatsApp accounts')); +apiRouter.get('/models', requireCap('discovery'), (req, res) => + discovery(res, () => mcpService.listModels(), 'Failed to list models')); +apiRouter.get('/google-accounts', requireCap('discovery'), (req, res) => + discovery(res, () => mcpService.listGoogleAccounts(), 'Failed to list Google accounts')); +apiRouter.get('/spreadsheets', requireCap('discovery'), (req, res) => + discovery(res, () => mcpService.searchSpreadsheets({ googleAccountId: req.query.googleAccountId, q: String(req.query.q || ''), pageSize: parseInt(req.query.pageSize || '50', 10) }), 'Failed to list spreadsheets')); +apiRouter.get('/spreadsheets/:id/tabs', requireCap('discovery'), (req, res) => + discovery(res, () => mcpService.listSheetTabs(req.query.googleAccountId, req.params.id), 'Failed to load spreadsheet tabs')); +apiRouter.get('/spreadsheets/:id/values', requireCap('discovery'), (req, res) => + discovery(res, () => mcpService.readSheetValues({ + googleAccountId: req.query.googleAccountId, + spreadsheetId: req.params.id, + tab: req.query.tab, + range: req.query.range || undefined, + maxRows: req.query.maxRows, + }), 'Failed to read spreadsheet values')); +apiRouter.get('/media', requireCap('discovery'), (req, res) => + discovery(res, () => mcpService.listMedia( + req.query.type ? String(req.query.type) : null, + req.query.name ? String(req.query.name) : null, + ), 'Failed to list media')); +apiRouter.get('/templates', requireCap('discovery'), (req, res) => + discovery(res, () => mcpService.listTemplates(req.query.waAccountId), 'Failed to list templates')); +apiRouter.get('/templates/:id', requireCap('discovery'), (req, res) => + discovery(res, () => mcpService.getTemplate(req.params.id), 'Failed to fetch template')); +apiRouter.get('/agents', requireCap('discovery'), async (req, res) => { + try { res.json(await agentService.listAgents()); } catch (err) { sendErr(res, err, 'Failed to list agents'); } +}); +apiRouter.get('/agents/:id', requireCap('discovery'), async (req, res) => { + try { + const agent = await agentService.getAgent(req.params.id); + if (!agent) return res.status(404).json({ error: 'Not found' }); + res.json(agent); + } catch (err) { sendErr(res, err, 'Failed to fetch agent'); } +}); + +/* --------- mutations --------- */ +apiRouter.post('/agents', requireCap('create_agent'), async (req, res) => { + try { res.status(201).json(await agentService.createAgent(req.body || {})); } catch (err) { sendErr(res, err, 'Failed to create agent'); } +}); +apiRouter.put('/agents/:id', requireCap('update_agent'), async (req, res) => { + try { res.json(await agentService.updateAgent(req.params.id, req.body || {})); } catch (err) { sendErr(res, err, 'Failed to update agent'); } +}); +apiRouter.post('/agents/:id/tools', requireCap('manage_tools'), async (req, res) => { + try { res.status(201).json(await agentService.addTool(req.params.id, req.body || {})); } catch (err) { sendErr(res, err, 'Failed to add tool'); } +}); +apiRouter.put('/agents/:id/tools/:toolId', requireCap('manage_tools'), async (req, res) => { + try { res.json(await agentService.updateTool(req.params.id, req.params.toolId, req.body || {})); } catch (err) { sendErr(res, err, 'Failed to update tool'); } +}); +apiRouter.delete('/agents/:id/tools/:toolId', requireCap('delete'), async (req, res) => { + try { res.json(await agentService.deleteTool(req.params.id, req.params.toolId)); } catch (err) { sendErr(res, err, 'Failed to delete tool'); } +}); +apiRouter.delete('/agents/:id', requireCap('delete'), async (req, res) => { + try { res.json(await agentService.deleteAgent(req.params.id)); } catch (err) { sendErr(res, err, 'Failed to delete agent'); } +}); + +module.exports = { adminRouter, apiRouter, ensureMcpTables }; diff --git a/backend/src/services/agentCloseSummary.js b/backend/src/services/agentCloseSummary.js new file mode 100644 index 0000000..8dd201d --- /dev/null +++ b/backend/src/services/agentCloseSummary.js @@ -0,0 +1,43 @@ +// Close-summary sweeper. Finds conversations that an idle-summary agent ran on +// but have since gone quiet (no new message for the agent's idle window) and +// aren't being handled by a human, then asks the agent to write its final +// summary. Runs on a timer from index.js. + +const pool = require('../db'); + +async function sweepClosedConversations() { + const { rows } = await pool.query( + `SELECT c.wa_number, c.contact_number, a.id AS agent_id + FROM coexistence.contacts c + JOIN coexistence.whatsapp_accounts w + ON regexp_replace(w.display_phone_number, '\\D', '', 'g') = regexp_replace(c.wa_number, '\\D', '', 'g') + JOIN coexistence.agents a + ON a.wa_account_id = w.id AND a.is_active = TRUE AND a.close_summary_enabled = TRUE + WHERE c.agent_close_pending = TRUE + AND c.agent_paused = FALSE + AND c.agent_last_run_at IS NOT NULL + AND c.agent_last_run_at < NOW() - make_interval(mins => GREATEST(1, a.close_idle_minutes)) + LIMIT 10`, + ); + + let done = 0; + for (const r of rows) { + // Claim atomically so concurrent sweeps don't double-summarise. + const { rowCount } = await pool.query( + `UPDATE coexistence.contacts SET agent_close_pending = FALSE + WHERE wa_number = $1 AND contact_number = $2 AND agent_close_pending = TRUE`, + [r.wa_number, r.contact_number], + ); + if (!rowCount) continue; + try { + const { runCloseSummary } = require('../engine/agentEngine'); + await runCloseSummary({ agentId: r.agent_id, waNumber: r.wa_number, contactNumber: r.contact_number }); + done++; + } catch (e) { + console.error('[closeSummary] sweep failed for', r.contact_number, e.message); + } + } + return done; +} + +module.exports = { sweepClosedConversations }; diff --git a/backend/src/services/agentCrmTools.js b/backend/src/services/agentCrmTools.js new file mode 100644 index 0000000..21b613a --- /dev/null +++ b/backend/src/services/agentCrmTools.js @@ -0,0 +1,88 @@ +// CRM write-back tools for the AI agent. +// +// These let an agent act on its OWN conversation's contact inside ForgeChat's +// native CRM (name, tags, custom fields) — not just an external sheet. They're +// only built when the agent has `crm_tools_enabled` AND there's a real live +// contact (skipped in the test preview). Each executor is scoped to a single +// (wa_number, contact_number) so the LLM can't touch other contacts. + +const pool = require('../db'); +const bus = require('../events'); + +function emit(event, payload) { try { bus.emit(event, payload); } catch { /* best-effort SSE */ } } + +// Normalize a field name to a stable key. MUST match the engine + frontend so +// name→field resolution agrees. +function fieldVarKey(name) { + return String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); +} + +async function resolveWaNumber(waAccountId) { + if (!waAccountId) return null; + const { rows } = await pool.query( + 'SELECT display_phone_number FROM coexistence.whatsapp_accounts WHERE id = $1', + [waAccountId], + ); + return rows[0]?.display_phone_number || null; +} + +// Build the CRM tool defs + executors scoped to one contact. +function buildCrmTools({ waNumber, contactNumber }) { + const tools = []; + const executors = {}; + const WHERE = 'wa_number = $1 AND contact_number = $2'; + const key = [waNumber, contactNumber]; + + tools.push({ + name: 'set_contact_name', + description: "Save the customer's name onto their ForgeChat CRM contact record. Call this once you learn their name so it shows on the Contacts page and chat.", + input_schema: { type: 'object', properties: { name: { type: 'string', description: 'The customer\'s name.' } }, required: ['name'] }, + }); + executors['set_contact_name'] = async ({ name }) => { + if (!name || !String(name).trim()) throw new Error('name is required'); + await pool.query(`UPDATE coexistence.contacts SET name = $3, updated_at = NOW() WHERE ${WHERE}`, [...key, String(name).trim()]); + emit('contact-saved', { waNumber, contactNumber }); + return { ok: true, name: String(name).trim() }; + }; + + tools.push({ + name: 'add_contact_tag', + description: 'Add an existing CRM tag to this contact (label leads, e.g. "Hot Lead", "Trial Booked"). The tag must already exist; if it doesn\'t this returns an error and you should just carry on.', + input_schema: { type: 'object', properties: { tag: { type: 'string', description: 'Exact tag name.' } }, required: ['tag'] }, + }); + executors['add_contact_tag'] = async ({ tag }) => { + if (!tag) throw new Error('tag is required'); + const { rows: tr } = await pool.query('SELECT id, name, color, category_id FROM coexistence.tags WHERE LOWER(name) = LOWER($1) LIMIT 1', [String(tag).trim()]); + if (!tr[0]) return { ok: false, error: `No tag named "${tag}" exists.` }; + const { rows: cr } = await pool.query(`SELECT tags FROM coexistence.contacts WHERE ${WHERE}`, key); + const cur = Array.isArray(cr[0]?.tags) ? cr[0].tags : []; + if (cur.some(t => String(t.id) === String(tr[0].id))) return { ok: true, already: true, tag: tr[0].name }; + const next = [...cur, { id: tr[0].id, name: tr[0].name, color: tr[0].color, category_id: tr[0].category_id }]; + await pool.query(`UPDATE coexistence.contacts SET tags = $3, updated_at = NOW() WHERE ${WHERE}`, [...key, JSON.stringify(next)]); + emit('contact-saved', { waNumber, contactNumber }); + return { ok: true, tag: tr[0].name }; + }; + + tools.push({ + name: 'set_contact_field', + description: 'Set a custom CRM field on this contact by the field NAME (e.g. "City", "Date of Birth"). The field must already be defined in the CRM; otherwise this returns the list of valid fields and you should carry on.', + input_schema: { type: 'object', properties: { field: { type: 'string', description: 'The field name.' }, value: { type: ['string', 'number', 'boolean'], description: 'The value to store.' } }, required: ['field', 'value'] }, + }); + executors['set_contact_field'] = async ({ field, value }) => { + if (!field) throw new Error('field is required'); + const { rows: fd } = await pool.query('SELECT id, name FROM coexistence.contact_field_definitions'); + const vk = fieldVarKey(field); + const def = fd.find(f => fieldVarKey(f.name) === vk || String(f.id) === String(field)); + if (!def) return { ok: false, error: `No CRM field named "${field}". Valid fields: ${fd.map(f => f.name).join(', ') || '(none)'}` }; + const { rows: cr } = await pool.query(`SELECT custom_fields FROM coexistence.contacts WHERE ${WHERE}`, key); + const cf = (cr[0]?.custom_fields && typeof cr[0].custom_fields === 'object') ? cr[0].custom_fields : {}; + cf[def.id] = value; + await pool.query(`UPDATE coexistence.contacts SET custom_fields = $3, updated_at = NOW() WHERE ${WHERE}`, [...key, JSON.stringify(cf)]); + emit('contact-saved', { waNumber, contactNumber }); + return { ok: true, field: def.name, value }; + }; + + return { tools, executors }; +} + +module.exports = { buildCrmTools, resolveWaNumber, fieldVarKey }; diff --git a/backend/src/services/agentHandoff.js b/backend/src/services/agentHandoff.js new file mode 100644 index 0000000..a51c12f --- /dev/null +++ b/backend/src/services/agentHandoff.js @@ -0,0 +1,87 @@ +// Human handoff for AI agents. +// +// The "controller" of a conversation is either the bot or a human. Handoff +// flips it to a human: round-robin a BDA from the agent's eligible pool, assign +// the contact, notify, and set contacts.agent_paused so routeIfActive() stops +// running the agent. resumeAgent() flips it back (manual "Return to bot"). + +const pool = require('../db'); +const bus = require('../events'); + +function emit(event, payload) { try { bus.emit(event, payload); } catch { /* best-effort SSE */ } } + +// Pick the next eligible BDA round-robin (advancing the agent's cursor +// atomically), assign + pause the conversation. `by` = 'agent' | 'keyword' | +// 'manual:'. Returns { assignedUserId, assignedUserName }. +async function performHandoff({ agentId, handoffUserIds, waNumber, contactNumber, reason, by, assignTo }) { + let assignedUserId = assignTo != null ? parseInt(assignTo, 10) || null : null; + let assignedUserName = null; + + // Round-robin choose a BDA (unless a specific assignee was given, e.g. a + // human clicking "take over" assigns it to themselves). + if (!assignedUserId) { + const ids = (Array.isArray(handoffUserIds) ? handoffUserIds : []).map(n => parseInt(n, 10)).filter(Boolean); + if (ids.length > 0) { + const { rows } = await pool.query( + `UPDATE coexistence.agents + SET handoff_rr_pointer = (COALESCE(handoff_rr_pointer, -1) + 1) % $2 + WHERE id = $1 + RETURNING handoff_rr_pointer`, + [agentId, ids.length], + ); + const idx = rows[0] ? ((rows[0].handoff_rr_pointer % ids.length) + ids.length) % ids.length : 0; + assignedUserId = ids[idx]; + } + } + + if (assignedUserId) { + const { rows: ur } = await pool.query( + `SELECT id, COALESCE(display_name, username) AS name FROM coexistence.forgecrm_users WHERE id = $1 AND is_active = TRUE`, + [assignedUserId], + ); + if (ur[0]) assignedUserName = ur[0].name; else assignedUserId = null; // skip a deactivated BDA + } + + await pool.query( + `UPDATE coexistence.contacts + SET agent_paused = TRUE, agent_paused_at = NOW(), + agent_paused_reason = $3, agent_paused_by = $4, + assigned_user_id = COALESCE($5, assigned_user_id), updated_at = NOW() + WHERE wa_number = $1 AND contact_number = $2`, + [waNumber, contactNumber, (reason || '').slice(0, 500) || null, by || 'agent', assignedUserId], + ); + + emit('contact-saved', { waNumber, contactNumber }); + if (assignedUserId) emit('contact-assignment-changed', { waNumber, contactNumber, assignedUserId }); + emit('agent-handoff', { waNumber, contactNumber, assignedUserId, assignedUserName, reason, by }); + return { assignedUserId, assignedUserName }; +} + +// Manual "Return to bot" — clear the pause so the agent answers again. +async function resumeAgent({ waNumber, contactNumber, by }) { + await pool.query( + `UPDATE coexistence.contacts + SET agent_paused = FALSE, agent_paused_at = NULL, agent_paused_reason = NULL, agent_paused_by = NULL, updated_at = NOW() + WHERE wa_number = $1 AND contact_number = $2`, + [waNumber, contactNumber], + ); + emit('agent-resumed', { waNumber, contactNumber, by }); + return { ok: true }; +} + +async function isConversationPaused(waNumber, contactNumber) { + const { rows } = await pool.query( + `SELECT agent_paused FROM coexistence.contacts WHERE wa_number = $1 AND contact_number = $2`, + [waNumber, contactNumber], + ); + return !!rows[0]?.agent_paused; +} + +// comma-separated keywords → does the message contain any? (case-insensitive) +function matchesAnyHandoffKeyword(messageBody, keywords) { + if (!messageBody || !keywords) return false; + const msg = String(messageBody).toLowerCase(); + return String(keywords).split(',').map(k => k.trim().toLowerCase()).filter(Boolean).some(k => msg.includes(k)); +} + +module.exports = { performHandoff, resumeAgent, isConversationPaused, matchesAnyHandoffKeyword }; diff --git a/backend/src/services/agentRouter.js b/backend/src/services/agentRouter.js index 4ef6d77..4db88c4 100644 --- a/backend/src/services/agentRouter.js +++ b/backend/src/services/agentRouter.js @@ -43,18 +43,21 @@ async function routeIfActive(record) { if (!record.contact_number) return null; const isAudio = (record.message_type === 'audio' || record.message_type === 'voice') && !!record.media_url; + const isImage = record.message_type === 'image' && !!record.media_url; // Audio/voice inbounds carry a placeholder body ("Audio message" / "Voice // message") set by the webhook — never a real caption (WhatsApp audio has no // caption field). Treat them as text-less so the transcribe_audio gate below // is actually honoured and the worker transcribes the audio instead of - // running the agent on the literal placeholder string. + // running the agent on the literal placeholder string. Images CAN carry a + // real caption (message_body), so an image's caption still counts as text. const hasText = !isAudio && !!(record.message_body && record.message_body.trim()); - if (!hasText && !isAudio) return null; // only text or voice notes are actionable + if (!hasText && !isAudio && !isImage) return null; // text, voice note, or image const { rows } = await pool.query( `SELECT a.id, a.wa_account_id, a.trigger_mode, a.trigger_keyword, a.trigger_match_type, a.trigger_case_sensitive, a.trigger_session_minutes, - a.transcribe_audio + a.transcribe_audio, a.accept_images, + a.handoff_enabled, a.handoff_user_ids, a.handoff_keywords FROM coexistence.agents a JOIN coexistence.whatsapp_accounts w ON w.id = a.wa_account_id WHERE a.is_active = TRUE @@ -67,20 +70,53 @@ async function routeIfActive(record) { const agent = rows[0]; + // Human handoff: if this conversation has been handed to a human, the bot + // stays silent until someone clicks "Return to bot". + const { isConversationPaused } = require('./agentHandoff'); + if (await isConversationPaused(record.wa_number, record.contact_number)) { + return { agentId: agent.id, skipped: 'paused_for_human' }; + } + // A voice note only runs when the agent has transcription enabled (the worker // turns it into text via Whisper). Otherwise the agent stays text-only. if (isAudio && !hasText && !agent.transcribe_audio) return null; - // Trigger gating. 'any' = run on every inbound. 'keyword' = engage on a - // keyword match OR an active session. A voice note can't be keyword-matched - // before it's transcribed, so in keyword mode it's handled only within an - // active session (after a keyword already engaged the agent). - if ((agent.trigger_mode || 'any') === 'keyword') { - const matched = hasText && matchesKeyword( - record.message_body, agent.trigger_keyword, - agent.trigger_match_type, agent.trigger_case_sensitive, - ); - if (!matched) { + // A caption-less image only runs when the agent accepts images (the worker + // sends the picture to the vision model). An image WITH a caption can still + // run text-only even when accept_images is off. + if (isImage && !hasText && !agent.accept_images) return null; + + // Trigger gating. + // 'any' = run on every inbound. + // 'keyword' = engage on a keyword match, then keep replying for the session. + // 'new' = engage only a BRAND-NEW conversation (the contact's first + // inbound — a new lead), then keep replying for the session. The + // agent never butts into conversations that already existed. + // For keyword/new, an inbound that isn't a fresh engagement only runs if + // there's already an active session (so multi-turn chats continue). A voice + // note can't be keyword-matched before transcription, so it's handled only + // within an active session. + const triggerMode = agent.trigger_mode || 'any'; + if (triggerMode === 'keyword' || triggerMode === 'new') { + let engages; + if (triggerMode === 'keyword') { + engages = hasText && matchesKeyword( + record.message_body, agent.trigger_keyword, + agent.trigger_match_type, agent.trigger_case_sensitive, + ); + } else { + // 'new': fresh conversation only if the contact has no earlier inbound + // message (besides the current one) to this number. + const { rows: prior } = await pool.query( + `SELECT 1 FROM coexistence.chat_history + WHERE wa_number = $1 AND contact_number = $2 AND direction = 'incoming' + AND ($3::text IS NULL OR message_id IS DISTINCT FROM $3) + LIMIT 1`, + [record.wa_number || '', record.contact_number, record.message_id || null], + ); + engages = prior.length === 0; + } + if (!engages) { const windowMin = agent.trigger_session_minutes || 30; const { rows: recent } = await pool.query( `SELECT 1 FROM coexistence.agent_runs @@ -89,7 +125,24 @@ async function routeIfActive(record) { LIMIT 1`, [agent.id, record.contact_number, windowMin], ); - if (recent.length === 0) return null; // no keyword match and no live session + if (recent.length === 0) return null; // not a fresh engagement and no live session + } + } + + // Keyword handoff: the customer asked for a human (or another configured + // word). Hand off instead of running the agent. + if (agent.handoff_enabled && hasText) { + const { matchesAnyHandoffKeyword, performHandoff } = require('./agentHandoff'); + if (matchesAnyHandoffKeyword(record.message_body, agent.handoff_keywords)) { + await performHandoff({ + agentId: agent.id, + handoffUserIds: agent.handoff_user_ids, + waNumber: record.wa_number, + contactNumber: record.contact_number, + reason: 'Customer asked for a human (keyword).', + by: 'keyword', + }); + return { agentId: agent.id, handedOff: true }; } } diff --git a/backend/src/services/agentService.js b/backend/src/services/agentService.js new file mode 100644 index 0000000..fe4444c --- /dev/null +++ b/backend/src/services/agentService.js @@ -0,0 +1,609 @@ +// Shared AI-Agent business logic — used by BOTH the cookie-authed routes +// (routes/agents.js) and the bearer-authed MCP API (routes/mcp.js). +// +// Every mutation here performs the exact same validation the in-app builder +// relied on (draft/active invariant, supported provider, WABA-uniqueness, +// Google Sheets config). Functions throw an ApiError { status, message } so the +// calling router maps it to the right HTTP code; everything else bubbles as a +// 500. + +const pool = require('../db'); + +const SUPPORTED_PROVIDERS = new Set(['anthropic', 'openai']); + +// Lightweight typed error so routers can map status → HTTP code. +class ApiError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +/* ----------------------------- shapers ------------------------------- */ + +function agentShape(row) { + if (!row) return null; + return { + id: row.id, + name: row.name, + description: row.description, + systemPrompt: row.system_prompt, + aiModelId: row.ai_model_id, + aiProvider: row.ai_provider || null, + aiModelLabel: row.ai_label || null, + llmModel: row.llm_model, + status: row.status || 'active', + waAccountId: row.wa_account_id, + isActive: row.is_active, + contextWindowMessages: row.context_window_messages, + maxToolIterations: row.max_tool_iterations, + transcribeAudio: !!row.transcribe_audio, + acceptImages: !!row.accept_images, + triggerMode: row.trigger_mode || 'any', + triggerKeyword: row.trigger_keyword || '', + triggerMatchType: row.trigger_match_type || 'contains', + triggerCaseSensitive: !!row.trigger_case_sensitive, + triggerSessionMinutes: row.trigger_session_minutes != null ? row.trigger_session_minutes : 30, + mediaGroups: Array.isArray(row.media_groups) ? row.media_groups : [], + crmToolsEnabled: !!row.crm_tools_enabled, + handoffEnabled: !!row.handoff_enabled, + handoffUserIds: Array.isArray(row.handoff_user_ids) ? row.handoff_user_ids : [], + handoffKeywords: row.handoff_keywords || '', + closeSummaryEnabled: !!row.close_summary_enabled, + closeIdleMinutes: row.close_idle_minutes != null ? row.close_idle_minutes : 30, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function toolShape(row) { + return { + id: row.id, + agentId: row.agent_id, + toolType: row.tool_type, + config: row.config || {}, + isEnabled: row.is_enabled, + createdAt: row.created_at, + }; +} + +/* --------------------------- normalizers ----------------------------- */ + +function cleanMatchType(v) { + return ['exact', 'contains', 'starts'].includes(v) ? v : 'contains'; +} + +function normalizeUrl(raw) { + let u = String(raw || '').trim(); + if (!u) return null; + if (!/^https?:\/\//i.test(u)) u = 'https://' + u; + if (!/^https?:\/\/[^\s.]+\.[^\s]+$/i.test(u)) return null; + return u.slice(0, 2048); +} + +// Eligible BDAs for round-robin handoff: a clean array of positive integer +// user ids (deduped, capped). +function sanitizeHandoffUserIds(raw) { + if (!Array.isArray(raw)) return []; + const out = []; + for (const v of raw) { + const n = parseInt(v, 10); + if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n); + if (out.length >= 50) break; + } + return out; +} + +// Comma-separated handoff trigger keywords → trimmed string (or null). +function sanitizeKeywords(raw) { + if (raw == null) return null; + const s = String(raw).split(',').map(k => k.trim()).filter(Boolean).slice(0, 20).join(', '); + return s || null; +} + +function normalizeMediaGroups(raw) { + if (!Array.isArray(raw)) return []; + return raw + .map(g => { + const tId = parseInt(g?.templateId, 10); + return { + description: typeof g?.description === 'string' ? g.description.trim().slice(0, 500) : '', + mediaIds: Array.isArray(g?.mediaIds) + ? [...new Set(g.mediaIds.map(n => parseInt(n, 10)).filter(Number.isInteger))] + : [], + links: Array.isArray(g?.links) + ? [...new Set(g.links.map(normalizeUrl).filter(Boolean))].slice(0, 20) + : [], + templateId: Number.isInteger(tId) ? tId : null, + templateName: typeof g?.templateName === 'string' ? g.templateName.slice(0, 200) : null, + templateLanguage: typeof g?.templateLanguage === 'string' ? g.templateLanguage.slice(0, 20) : null, + }; + }) + .filter(g => g.description && (g.mediaIds.length > 0 || g.links.length > 0 || g.templateId != null)); +} + +async function getAiModel(id) { + if (id == null || id === '') return null; + const { rows } = await pool.query( + 'SELECT id, provider FROM coexistence.ai_models WHERE id = $1', + [id], + ); + return rows[0] || null; +} + +async function fetchAgent(id) { + const { rows } = await pool.query( + `SELECT a.*, am.provider AS ai_provider, am.label AS ai_label + FROM coexistence.agents a + LEFT JOIN coexistence.ai_models am ON am.id = a.ai_model_id + WHERE a.id = $1`, + [id], + ); + return agentShape(rows[0]); +} + +/* ------------------------------ reads -------------------------------- */ + +async function listAgents() { + const { rows } = await pool.query( + `SELECT a.*, + am.provider AS ai_provider, + am.label AS ai_label, + (SELECT COUNT(*)::int FROM coexistence.agent_tools t WHERE t.agent_id = a.id) AS tool_count, + (SELECT MAX(started_at) FROM coexistence.agent_runs r WHERE r.agent_id = a.id) AS last_run_at + FROM coexistence.agents a + LEFT JOIN coexistence.ai_models am ON am.id = a.ai_model_id + ORDER BY a.updated_at DESC`, + ); + return rows.map(r => ({ + ...agentShape(r), + toolCount: r.tool_count, + lastRunAt: r.last_run_at, + })); +} + +// Returns { ...agent, tools[] } or null if the agent doesn't exist. +async function getAgent(id) { + const { rows } = await pool.query( + `SELECT a.*, am.provider AS ai_provider, am.label AS ai_label + FROM coexistence.agents a + LEFT JOIN coexistence.ai_models am ON am.id = a.ai_model_id + WHERE a.id = $1`, + [id], + ); + if (rows.length === 0) return null; + const { rows: tools } = await pool.query( + `SELECT * FROM coexistence.agent_tools WHERE agent_id = $1 ORDER BY id`, + [id], + ); + return { ...agentShape(rows[0]), tools: tools.map(toolShape) }; +} + +/* ----------------------------- mutations ----------------------------- */ + +// Map a Postgres unique-violation (one active agent per WABA) to a 409. +function mapPgError(err) { + if (err.code === '23505') { + return new ApiError(409, 'Another agent is already active on this WhatsApp account. Disable it first.'); + } + return err; +} + +async function createAgent(b = {}) { + if (!b.name || !b.systemPrompt) { + throw new ApiError(400, 'name and systemPrompt are required'); + } + const status = b.status === 'draft' ? 'draft' : 'active'; + const aiModelId = b.aiModelId || null; + const llmModel = b.llmModel ? String(b.llmModel).trim() : null; + + if (status === 'active') { + if (!aiModelId || !llmModel) { + throw new ApiError(400, 'An active agent needs a connected AI model and a model selection.'); + } + const model = await getAiModel(aiModelId); + if (!model) throw new ApiError(400, 'Selected AI model no longer exists.'); + if (!SUPPORTED_PROVIDERS.has(model.provider)) { + throw new ApiError(400, `Provider '${model.provider}' isn't supported by agents.`); + } + } else if (aiModelId) { + const model = await getAiModel(aiModelId); + if (!model) throw new ApiError(400, 'Selected AI model no longer exists.'); + } + const isActive = status === 'active' ? !!b.isActive : false; + + const triggerMode = ['keyword', 'new'].includes(b.triggerMode) ? b.triggerMode : 'any'; + const triggerKeyword = typeof b.triggerKeyword === 'string' ? b.triggerKeyword.trim().slice(0, 200) : ''; + if (status === 'active' && triggerMode === 'keyword' && !triggerKeyword) { + throw new ApiError(400, 'A keyword-triggered agent needs a keyword.'); + } + const mediaGroups = normalizeMediaGroups(b.mediaGroups); + + try { + const { rows } = await pool.query( + `INSERT INTO coexistence.agents + (name, description, system_prompt, ai_model_id, llm_model, + status, wa_account_id, is_active, + context_window_messages, max_tool_iterations, + trigger_mode, trigger_keyword, trigger_match_type, + trigger_case_sensitive, trigger_session_minutes, media_groups, + transcribe_audio, accept_images, + crm_tools_enabled, handoff_enabled, handoff_user_ids, handoff_keywords, + close_summary_enabled, close_idle_minutes) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24) + RETURNING id`, + [ + b.name.trim(), b.description?.trim() || null, + b.systemPrompt, aiModelId, llmModel, + status, b.waAccountId || null, isActive, + Math.max(1, Math.min(100, parseInt(b.contextWindowMessages || 20, 10))), + Math.max(1, Math.min(20, parseInt(b.maxToolIterations || 6, 10))), + triggerMode, triggerKeyword || null, cleanMatchType(b.triggerMatchType), + !!b.triggerCaseSensitive, + Math.max(1, Math.min(1440, parseInt(b.triggerSessionMinutes || 30, 10))), + JSON.stringify(mediaGroups), + !!b.transcribeAudio, + !!b.acceptImages, + !!b.crmToolsEnabled, + !!b.handoffEnabled, + JSON.stringify(sanitizeHandoffUserIds(b.handoffUserIds)), + sanitizeKeywords(b.handoffKeywords), + !!b.closeSummaryEnabled, + Math.max(1, Math.min(1440, parseInt(b.closeIdleMinutes || 30, 10))), + ], + ); + return await fetchAgent(rows[0].id); + } catch (err) { + throw mapPgError(err); + } +} + +async function updateAgent(id, b = {}) { + const { rows: existing } = await pool.query( + 'SELECT * FROM coexistence.agents WHERE id = $1', + [id], + ); + if (existing.length === 0) throw new ApiError(404, 'Not found'); + + const cur = existing[0]; + const effStatus = b.status !== undefined ? (b.status === 'draft' ? 'draft' : 'active') : (cur.status || 'active'); + const effModelId = b.aiModelId !== undefined ? (b.aiModelId || null) : cur.ai_model_id; + const effLlmModel = b.llmModel !== undefined ? (b.llmModel ? String(b.llmModel).trim() : null) : cur.llm_model; + let effIsActive = b.isActive !== undefined ? !!b.isActive : cur.is_active; + if (effStatus === 'draft') effIsActive = false; + + if (effModelId) { + const model = await getAiModel(effModelId); + if (!model) throw new ApiError(400, 'Selected AI model no longer exists.'); + if ((effStatus === 'active' || effIsActive) && !SUPPORTED_PROVIDERS.has(model.provider)) { + throw new ApiError(400, `Provider '${model.provider}' isn't supported by agents.`); + } + } + if ((effStatus === 'active' || effIsActive) && (!effModelId || !effLlmModel)) { + throw new ApiError(400, 'An active agent needs a connected AI model and a model selection.'); + } + + const effTrigMode = b.triggerMode !== undefined ? (['keyword', 'new'].includes(b.triggerMode) ? b.triggerMode : 'any') : (cur.trigger_mode || 'any'); + const effTrigKeyword = b.triggerKeyword !== undefined ? String(b.triggerKeyword || '').trim() : (cur.trigger_keyword || ''); + if ((effStatus === 'active' || effIsActive) && effTrigMode === 'keyword' && !effTrigKeyword) { + throw new ApiError(400, 'A keyword-triggered agent needs a keyword.'); + } + + const sets = ['updated_at = NOW()']; + const params = []; + let i = 1; + const push = (col, val) => { sets.push(`${col} = $${i++}`); params.push(val); }; + + if (b.name !== undefined) push('name', b.name.trim()); + if (b.description !== undefined) push('description', b.description?.trim() || null); + if (b.systemPrompt !== undefined) push('system_prompt', b.systemPrompt); + if (b.aiModelId !== undefined) push('ai_model_id', effModelId); + if (b.llmModel !== undefined) push('llm_model', effLlmModel); + if (b.status !== undefined) push('status', effStatus); + if (b.waAccountId !== undefined) push('wa_account_id', b.waAccountId || null); + if (b.isActive !== undefined || b.status !== undefined) push('is_active', effIsActive); + if (b.contextWindowMessages !== undefined) { + push('context_window_messages', Math.max(1, Math.min(100, parseInt(b.contextWindowMessages, 10) || 20))); + } + if (b.transcribeAudio !== undefined) push('transcribe_audio', !!b.transcribeAudio); + if (b.acceptImages !== undefined) push('accept_images', !!b.acceptImages); + if (b.maxToolIterations !== undefined) { + push('max_tool_iterations', Math.max(1, Math.min(20, parseInt(b.maxToolIterations, 10) || 6))); + } + if (b.triggerMode !== undefined) push('trigger_mode', effTrigMode); + if (b.triggerKeyword !== undefined) push('trigger_keyword', effTrigKeyword || null); + if (b.triggerMatchType !== undefined) push('trigger_match_type', cleanMatchType(b.triggerMatchType)); + if (b.triggerCaseSensitive !== undefined) push('trigger_case_sensitive', !!b.triggerCaseSensitive); + if (b.triggerSessionMinutes !== undefined) { + push('trigger_session_minutes', Math.max(1, Math.min(1440, parseInt(b.triggerSessionMinutes, 10) || 30))); + } + if (b.mediaGroups !== undefined) push('media_groups', JSON.stringify(normalizeMediaGroups(b.mediaGroups))); + if (b.crmToolsEnabled !== undefined) push('crm_tools_enabled', !!b.crmToolsEnabled); + if (b.handoffEnabled !== undefined) push('handoff_enabled', !!b.handoffEnabled); + if (b.handoffUserIds !== undefined) push('handoff_user_ids', JSON.stringify(sanitizeHandoffUserIds(b.handoffUserIds))); + if (b.handoffKeywords !== undefined) push('handoff_keywords', sanitizeKeywords(b.handoffKeywords)); + if (b.closeSummaryEnabled !== undefined) push('close_summary_enabled', !!b.closeSummaryEnabled); + if (b.closeIdleMinutes !== undefined) push('close_idle_minutes', Math.max(1, Math.min(1440, parseInt(b.closeIdleMinutes, 10) || 30))); + + params.push(id); + try { + const { rows } = await pool.query( + `UPDATE coexistence.agents SET ${sets.join(', ')} WHERE id = $${i} RETURNING id`, + params, + ); + return await fetchAgent(rows[0].id); + } catch (err) { + throw mapPgError(err); + } +} + +async function deleteAgent(id) { + const { rowCount } = await pool.query( + 'DELETE FROM coexistence.agents WHERE id = $1', + [id], + ); + if (rowCount === 0) throw new ApiError(404, 'Not found'); + return { ok: true }; +} + +/* --------------------------- export / import ------------------------- */ + +// Portable agent file: full config + tools, no internal ids (kept only as +// best-effort relink hints). Secrets in tool configs (e.g. HTTP auth headers) +// ARE included so the imported agent works — the file is admin-only. +async function exportAgent(id) { + const full = await getAgent(id); + if (!full) throw new ApiError(404, 'Not found'); + return { + type: 'forgechat.agent', + version: 1, + agent: { + name: full.name, + description: full.description, + systemPrompt: full.systemPrompt, + llmModel: full.llmModel, + aiProvider: full.aiProvider, // hint for cross-instance model match + aiModelLabel: full.aiModelLabel, // hint + aiModelId: full.aiModelId, // same-instance relink + waAccountId: full.waAccountId, // same-instance relink + contextWindowMessages: full.contextWindowMessages, + maxToolIterations: full.maxToolIterations, + transcribeAudio: full.transcribeAudio, + acceptImages: full.acceptImages, + triggerMode: full.triggerMode, + triggerKeyword: full.triggerKeyword, + triggerMatchType: full.triggerMatchType, + triggerCaseSensitive: full.triggerCaseSensitive, + triggerSessionMinutes: full.triggerSessionMinutes, + mediaGroups: full.mediaGroups, + }, + tools: (full.tools || []).map(t => ({ toolType: t.toolType, config: t.config, isEnabled: t.isEnabled })), + }; +} + +// Resolve an exported model reference to a local ai_models id: exact id first, +// then provider+label, then provider alone. Returns null when nothing matches. +async function resolveModelId({ aiModelId, aiProvider, aiModelLabel }) { + if (aiModelId) { + const m = await getAiModel(aiModelId); + if (m) return aiModelId; + } + if (aiProvider) { + const { rows } = await pool.query( + `SELECT id FROM coexistence.ai_models + WHERE provider = $1 AND ($2::text IS NULL OR label = $2) + ORDER BY id LIMIT 1`, + [aiProvider, aiModelLabel || null], + ); + if (rows[0]) return rows[0].id; + const { rows: any } = await pool.query( + `SELECT id FROM coexistence.ai_models WHERE provider = $1 ORDER BY id LIMIT 1`, + [aiProvider], + ); + if (any[0]) return any[0].id; + } + return null; +} + +// Create a NEW agent from an export file. Always lands as a draft (never +// auto-activates), relinks model/number when they resolve here, and re-adds +// every tool. Returns { agent, warnings }. +async function importAgent(payload = {}) { + if (!payload || payload.type !== 'forgechat.agent' || !payload.agent) { + throw new ApiError(400, 'That file is not a ForgeChat agent export.'); + } + const a = payload.agent; + if (!a.name || !a.systemPrompt) { + throw new ApiError(400, 'The export file is missing required agent fields (name / system prompt).'); + } + + const aiModelId = await resolveModelId(a); + const llmModel = aiModelId ? (a.llmModel || null) : null; + + let waAccountId = null; + if (a.waAccountId) { + const { rows } = await pool.query('SELECT id FROM coexistence.whatsapp_accounts WHERE id = $1', [a.waAccountId]); + if (rows[0]) waAccountId = a.waAccountId; + } + + const created = await createAgent({ + name: `${a.name} (imported)`.slice(0, 200), + description: a.description || null, + systemPrompt: a.systemPrompt, + aiModelId, + llmModel, + waAccountId, + status: 'draft', // always import as a draft; user reviews + activates + isActive: false, + contextWindowMessages: a.contextWindowMessages, + maxToolIterations: a.maxToolIterations, + transcribeAudio: a.transcribeAudio, + acceptImages: a.acceptImages, + triggerMode: a.triggerMode, + triggerKeyword: a.triggerKeyword, + triggerMatchType: a.triggerMatchType, + triggerCaseSensitive: a.triggerCaseSensitive, + triggerSessionMinutes: a.triggerSessionMinutes, + mediaGroups: a.mediaGroups, + }); + + const warnings = []; + if (a.aiModelId && !aiModelId) warnings.push('The AI model from the file was not found here — pick a model before going live.'); + if (a.waAccountId && !waAccountId) warnings.push('The WhatsApp number from the file was not found here — pick a number before going live.'); + + for (const t of (Array.isArray(payload.tools) ? payload.tools : [])) { + try { + await addTool(created.id, { toolType: t.toolType, config: t.config, isEnabled: t.isEnabled }); + } catch (e) { + warnings.push(`Skipped a ${t.toolType || 'tool'}: ${e.message}`); + } + } + + const full = await getAgent(created.id); + return { agent: full, warnings }; +} + +/* ------------------------------ tools -------------------------------- */ + +const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); +const PARAM_LOCATIONS = new Set(['path', 'query', 'body', 'header']); +const PARAM_TYPES = new Set(['string', 'number', 'boolean']); + +// Validate + normalise an http_request tool config. Returns the cleaned config. +// The admin owns method/url/static headers; the agent's LLM only fills the +// declared params at call time (see agentEngine http_request executor). +function validateHttpConfig(cfg = {}) { + const label = String(cfg.label || '').trim(); + if (!label) throw new ApiError(400, 'Give the HTTP tool a name (label).'); + const description = String(cfg.description || '').trim(); + if (!description) throw new ApiError(400, 'Describe when the agent should use this HTTP tool — the AI needs it to decide.'); + + const method = String(cfg.method || 'GET').trim().toUpperCase(); + if (!HTTP_METHODS.has(method)) throw new ApiError(400, `Method must be one of ${[...HTTP_METHODS].join(', ')}.`); + + const url = normalizeUrl(cfg.url); + if (!url) throw new ApiError(400, 'Enter a valid http(s) URL for the HTTP tool.'); + + const headers = Array.isArray(cfg.headers) + ? cfg.headers + .map(h => ({ k: String(h?.k || '').trim(), v: String(h?.v ?? '').trim() })) + .filter(h => h.k) + .slice(0, 30) + : []; + + const seen = new Set(); + const params = Array.isArray(cfg.params) + ? cfg.params.map((p, idx) => { + const name = String(p?.name || '').trim(); + if (!name) throw new ApiError(400, `Parameter #${idx + 1} needs a name.`); + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new ApiError(400, `Parameter "${name}" must be a simple identifier (letters, numbers, underscore; no leading digit).`); + } + if (seen.has(name)) throw new ApiError(400, `Duplicate parameter name "${name}".`); + seen.add(name); + const loc = PARAM_LOCATIONS.has(p?.in) ? p.in : 'body'; + const type = PARAM_TYPES.has(p?.type) ? p.type : 'string'; + return { + name, + in: loc, + type, + description: String(p?.description || '').trim().slice(0, 500), + required: !!p?.required, + }; + }).slice(0, 30) + : []; + + // Every {placeholder} in the URL must be backed by a path param. + const placeholders = [...url.matchAll(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g)].map(m => m[1]); + for (const ph of placeholders) { + const p = params.find(x => x.name === ph); + if (!p) throw new ApiError(400, `URL placeholder {${ph}} has no matching parameter — add a "path" parameter named "${ph}".`); + if (p.in !== 'path') throw new ApiError(400, `Parameter "${ph}" is used in the URL path, so its location must be "path".`); + } + + const timeoutMs = Math.max(1000, Math.min(30000, parseInt(cfg.timeout_ms || 10000, 10) || 10000)); + + return { label: label.slice(0, 120), description: description.slice(0, 1000), method, url, headers, params, timeout_ms: timeoutMs }; +} + +// Validate a tool body by type; returns the cleaned config to persist. +function validateToolConfig(toolType, config) { + if (toolType === 'google_sheets') { + const cfg = config; + if (!cfg.google_account_id || !cfg.spreadsheet_id || !cfg.sheet_name) { + throw new ApiError(400, 'Sheets tool needs google_account_id, spreadsheet_id, sheet_name.'); + } + if (!Array.isArray(cfg.ops) || cfg.ops.length === 0) { + throw new ApiError(400, 'Enable at least one operation (read / append / update).'); + } + return cfg; + } + if (toolType === 'http_request') { + return validateHttpConfig(config); + } + return config; +} + +async function addTool(agentId, b = {}) { + if (!b.toolType || !b.config) { + throw new ApiError(400, 'toolType and config are required'); + } + const cleanConfig = validateToolConfig(b.toolType, b.config); + const { rows } = await pool.query( + `INSERT INTO coexistence.agent_tools (agent_id, tool_type, config, is_enabled) + VALUES ($1,$2,$3,$4) RETURNING *`, + [agentId, b.toolType, JSON.stringify(cleanConfig), b.isEnabled !== false], + ); + return toolShape(rows[0]); +} + +async function updateTool(agentId, toolId, b = {}) { + const sets = []; + const params = []; + let i = 1; + if (b.config !== undefined) { + // Re-validate against the existing tool's type so an edit can't store junk. + const { rows: cur } = await pool.query( + 'SELECT tool_type FROM coexistence.agent_tools WHERE agent_id = $1 AND id = $2', + [agentId, toolId], + ); + if (cur.length === 0) throw new ApiError(404, 'Not found'); + const cleanConfig = validateToolConfig(cur[0].tool_type, b.config); + sets.push(`config = $${i++}`); params.push(JSON.stringify(cleanConfig)); + } + if (b.isEnabled !== undefined) { sets.push(`is_enabled = $${i++}`); params.push(!!b.isEnabled); } + if (sets.length === 0) throw new ApiError(400, 'No updatable fields provided'); + params.push(agentId, toolId); + const { rows } = await pool.query( + `UPDATE coexistence.agent_tools SET ${sets.join(', ')} + WHERE agent_id = $${i++} AND id = $${i} RETURNING *`, + params, + ); + if (rows.length === 0) throw new ApiError(404, 'Not found'); + return toolShape(rows[0]); +} + +async function deleteTool(agentId, toolId) { + const { rowCount } = await pool.query( + 'DELETE FROM coexistence.agent_tools WHERE agent_id = $1 AND id = $2', + [agentId, toolId], + ); + if (rowCount === 0) throw new ApiError(404, 'Not found'); + return { ok: true }; +} + +module.exports = { + ApiError, + agentShape, + toolShape, + listAgents, + getAgent, + createAgent, + updateAgent, + deleteAgent, + addTool, + updateTool, + deleteTool, + exportAgent, + importAgent, +}; diff --git a/backend/src/services/googleSheets.js b/backend/src/services/googleSheets.js index 1ce08e3..630657c 100644 --- a/backend/src/services/googleSheets.js +++ b/backend/src/services/googleSheets.js @@ -11,6 +11,30 @@ const { google } = require('googleapis'); const { getAccessToken } = require('./googleAuth'); +// ── helpers for the `upsert` op ──────────────────────────────────────────── +function colLetter(idx) { + let s = ''; let n = Number(idx); + do { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; } while (n >= 0); + return s; +} +function normCell(v) { return String(v == null ? '' : v).trim().toLowerCase(); } +function digitsOnly(v) { return String(v == null ? '' : v).replace(/\D/g, ''); } +// Match a cell against a key — exact (case-insensitive) OR digits-only (so +// "+91 94877 22330" matches "919487722330" for phone-number keys). +function keyMatch(cell, key) { + if (normCell(cell) !== '' && normCell(cell) === normCell(key)) return true; + const dc = digitsOnly(cell); const dk = digitsOnly(key); + return dc !== '' && dc === dk; +} +// Parse the start row/col index from a returned A1 range like +// "'Enquiry tracker'!A1:G23" → { row: 1, col: 0 } (1-based row, 0-based col). +function parseStart(rangeA1) { + const m = String(rangeA1 || '').match(/!\$?([A-Z]+)\$?(\d+)/); + if (!m) return { row: 1, col: 0 }; + let col = 0; for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64); + return { row: parseInt(m[2], 10), col: col - 1 }; +} + async function authedSheets(credentialId) { const token = await getAccessToken(credentialId); const oauth2 = new google.auth.OAuth2(); @@ -100,17 +124,37 @@ async function append({ credentialId, spreadsheetId, sheetName, args = {} }) { throw new Error('append requires args.values (array)'); } const sheets = await authedSheets(credentialId); - const { data } = await sheets.spreadsheets.values.append({ + // Deterministic placement: read the tab, find the LAST non-empty row, and + // write the new row right after it via values.update at an explicit range. + // We do NOT use values.append — its "table detection" lands on the styled + // header row when the tab is a Sheets "Table" (title banner in row 1 + + // dark-blue header in row 2), which made rows overwrite the header band. + // This way the agent never chooses a row number; the code computes it. + const { data: read } = await sheets.spreadsheets.values.get({ + spreadsheetId, + range: `'${sheetName}'!A1:Z2000`, + valueRenderOption: 'UNFORMATTED_VALUE', + }); + const rows = read.values || []; + const start = parseStart(read.range); // 1-based first row of the read window + let lastNonEmpty = -1; + for (let i = 0; i < rows.length; i++) { + if ((rows[i] || []).some(c => String(c == null ? '' : c).trim() !== '')) lastNonEmpty = i; + } + const targetRow = start.row + lastNonEmpty + 1; // first empty row after content + const endCol = colLetter(Math.max(0, args.values.length - 1)); + const { data } = await sheets.spreadsheets.values.update({ spreadsheetId, - range: `'${sheetName}'`, + range: `'${sheetName}'!A${targetRow}:${endCol}${targetRow}`, valueInputOption: 'USER_ENTERED', - insertDataOption: 'INSERT_ROWS', requestBody: { values: [args.values] }, }); return { - updatedRange: data.updates?.updatedRange, - updatedRows: data.updates?.updatedRows, - updatedCells: data.updates?.updatedCells, + action: 'appended', + row: targetRow, + updatedRange: data.updatedRange, + updatedRows: data.updatedRows, + updatedCells: data.updatedCells, }; } @@ -138,6 +182,90 @@ async function update({ credentialId, spreadsheetId, sheetName, args = {} }) { }; } +/** + * Tool op: find-or-create a row by a key column, writing only named columns. + * The engine handles header discovery, row numbers and the column offset so the + * LLM never deals with A1 ranges — it just gives + * { key_column, key_value, fields:{ "Header Name": value } }. The reliable way + * to keep a single evolving row per contact (vs. raw append/update). + */ +async function upsert({ credentialId, spreadsheetId, sheetName, args = {} }) { + const keyColumn = args.key_column; + const keyValue = args.key_value; + const fields = args.fields; + if (!keyColumn) throw new Error('upsert requires args.key_column (a header name, e.g. "Phone Number")'); + if (keyValue == null || keyValue === '') throw new Error('upsert requires args.key_value'); + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { + throw new Error('upsert requires args.fields as an object of { "Column Header": value, ... }'); + } + const sheets = await authedSheets(credentialId); + const { data: readData } = await sheets.spreadsheets.values.get({ + spreadsheetId, range: `'${sheetName}'!A1:Z2000`, valueRenderOption: 'UNFORMATTED_VALUE', + }); + const rows = readData.values || []; + const start = parseStart(readData.range); + + // 1. Locate the header row (first row that contains the key column name). + let hIdx = -1; + for (let i = 0; i < rows.length; i++) { + if ((rows[i] || []).some(c => normCell(c) === normCell(keyColumn))) { hIdx = i; break; } + } + if (hIdx === -1) throw new Error(`Could not find a header row containing "${keyColumn}" in tab "${sheetName}". Check the column name.`); + const header = rows[hIdx]; + const colOf = (name) => header.findIndex(c => normCell(c) === normCell(name)); + const keyCol = colOf(keyColumn); + + // 2. Resolve each field name → its column index (skip unknown columns). + const writes = []; const skippedUnknownColumns = []; + for (const [name, value] of Object.entries(fields)) { + const c = colOf(name); + if (c === -1) skippedUnknownColumns.push(name); else writes.push({ col: c, value }); + } + if (writes.length === 0) throw new Error(`None of the given field names matched a column header in "${sheetName}". Headers are: ${header.filter(Boolean).join(', ')}`); + + // 3. Find the existing data row whose key column matches key_value. + let dIdx = -1; + for (let i = hIdx + 1; i < rows.length; i++) { + if (keyMatch((rows[i] || [])[keyCol], keyValue)) { dIdx = i; break; } + } + + const writeSpan = async (absRow, minC, maxC, span) => { + const { data } = await sheets.spreadsheets.values.update({ + spreadsheetId, range: `'${sheetName}'!${colLetter(minC)}${absRow}:${colLetter(maxC)}${absRow}`, + valueInputOption: 'USER_ENTERED', requestBody: { values: [span] }, + }); + return data; + }; + + if (dIdx !== -1) { + const absRow = start.row + dIdx; + const cols = writes.map(w => w.col); + const minC = Math.min(...cols); const maxC = Math.max(...cols); + const existing = rows[dIdx] || []; + const span = []; + for (let c = minC; c <= maxC; c++) { + const w = writes.find(x => x.col === c); + span.push(w ? w.value : (existing[c] != null ? existing[c] : '')); + } + await writeSpan(absRow, minC, maxC, span); + return { action: 'updated', row: absRow, key: { column: keyColumn, value: keyValue }, wrote: writes.map(w => header[w.col]), skippedUnknownColumns }; + } + + // 4. No match → write a new positioned row after the last non-empty line + // (deterministic; avoids Google append landing on a Tables header banner). + let lastNonEmpty = -1; + for (let i = 0; i < rows.length; i++) { + if ((rows[i] || []).some(c => String(c == null ? '' : c).trim() !== '')) lastNonEmpty = i; + } + const targetRow = start.row + lastNonEmpty + 1; + const maxC = Math.max(keyCol, ...writes.map(w => w.col)); + const rowArr = new Array(maxC + 1).fill(''); + rowArr[keyCol] = keyValue; + for (const w of writes) rowArr[w.col] = w.value; + await writeSpan(targetRow, 0, maxC, rowArr); + return { action: 'appended', row: targetRow, key: { column: keyColumn, value: keyValue }, wrote: writes.map(w => header[w.col]), skippedUnknownColumns }; +} + /** * Dispatcher used by the agent engine. Looks at the tool's `config.ops` to * gate which operations the LLM is allowed to call — defense in depth, since @@ -157,6 +285,7 @@ async function executeOp({ op, toolConfig, args }) { if (op === 'read') return read(ctx); if (op === 'append') return append(ctx); if (op === 'update') return update(ctx); + if (op === 'upsert') return upsert(ctx); throw new Error(`Unknown Sheets op: ${op}`); } @@ -166,5 +295,6 @@ module.exports = { read, append, update, + upsert, executeOp, }; diff --git a/backend/src/services/mcpService.js b/backend/src/services/mcpService.js new file mode 100644 index 0000000..629a896 --- /dev/null +++ b/backend/src/services/mcpService.js @@ -0,0 +1,218 @@ +// Shared MCP logic used by BOTH transports: +// - routes/mcp.js (REST: /api/mcp/v1/*, bearer header — for the stdio server) +// - mcpHttp.js (Streamable HTTP: /api/mcp/http/:key — remote connector) +// +// Holds the settings/key-validation + discovery queries so the two transports +// can't drift. Agent create/update/tool ops live in services/agentService.js. + +const pool = require('../db'); +const { hashApiKey } = require('../util/crypto'); +const googleSheets = require('./googleSheets'); // Forge-Chat per-account Google client + +// Static LLM catalog — keep in sync with frontend/src/components/agents/modelCatalog.js. +const MODEL_CATALOG = { + anthropic: [ + { value: 'claude-opus-4-7', label: 'Claude Opus 4.7 (most capable)' }, + { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (balanced)' }, + { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 (fastest)' }, + ], + openai: [ + { value: 'gpt-4o', label: 'GPT-4o' }, + { value: 'gpt-4o-mini', label: 'GPT-4o mini' }, + { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' }, + ], +}; +const PROVIDER_LABELS = { anthropic: 'Anthropic Claude', openai: 'OpenAI', claude_code: 'Claude Code' }; +const CAPABILITY_KEYS = ['discovery', 'create_agent', 'update_agent', 'manage_tools', 'delete']; + +/* --------------------------- tables + settings --------------------------- */ + +// Self-healing table creation (mirrors migration 053_mcp.sql). +async function ensureMcpTables() { + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.mcp_api_keys ( + id BIGSERIAL PRIMARY KEY, + label TEXT NOT NULL, + key_prefix TEXT NOT NULL, + key_last4 TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_used_at TIMESTAMPTZ, + created_by BIGINT REFERENCES coexistence.forgecrm_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`); + await pool.query(` + CREATE TABLE IF NOT EXISTS coexistence.mcp_settings ( + id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + master_enabled BOOLEAN NOT NULL DEFAULT FALSE, + capabilities JSONB NOT NULL DEFAULT '{"discovery":true,"create_agent":true,"update_agent":true,"manage_tools":true,"delete":true}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`); + await pool.query(`INSERT INTO coexistence.mcp_settings (id) VALUES (1) ON CONFLICT DO NOTHING`); +} + +async function loadSettings() { + const { rows } = await pool.query('SELECT master_enabled, capabilities FROM coexistence.mcp_settings WHERE id = 1'); + const r = rows[0] || { master_enabled: false, capabilities: {} }; + return { masterEnabled: !!r.master_enabled, capabilities: r.capabilities || {} }; +} + +// Validate a raw bearer key. Returns { capabilities, keyId } on success. +// Throws { status, message } on any failure so callers map to HTTP codes. +async function validateKey(rawKey) { + const key = String(rawKey || '').trim(); + if (!key) { const e = new Error('Missing API key'); e.status = 401; throw e; } + const { rows } = await pool.query( + 'SELECT * FROM coexistence.mcp_api_keys WHERE key_hash = $1', + [hashApiKey(key)], + ); + const row = rows[0]; + if (!row || !row.is_enabled) { const e = new Error('Invalid or disabled API key'); e.status = 401; throw e; } + const settings = await loadSettings(); + if (!settings.masterEnabled) { const e = new Error('MCP access is disabled'); e.status = 403; throw e; } + pool.query('UPDATE coexistence.mcp_api_keys SET last_used_at = NOW() WHERE id = $1', [row.id]).catch(() => {}); + return { keyId: row.id, capabilities: settings.capabilities }; +} + +/* ------------------------------ discovery ------------------------------- */ + +async function listWaAccounts() { + const { rows } = await pool.query( + `SELECT id, display_name, display_phone_number, is_active, is_default + FROM coexistence.whatsapp_accounts ORDER BY is_default DESC, display_name`, + ); + return rows.map(r => ({ + id: r.id, + displayName: r.display_name, + phoneNumber: r.display_phone_number, + isActive: r.is_active, + isDefault: r.is_default, + })); +} + +async function listModels() { + const { rows } = await pool.query( + `SELECT id, provider, label FROM coexistence.ai_models ORDER BY created_at DESC`, + ); + return rows + .filter(r => MODEL_CATALOG[r.provider]) + .map(r => ({ + aiModelId: r.id, + provider: r.provider, + providerLabel: PROVIDER_LABELS[r.provider] || r.provider, + label: r.label, + models: MODEL_CATALOG[r.provider], + })); +} + +// Forge-Chat's Google integration is PER-ACCOUNT: spreadsheets/tabs are listed +// against a connected Google account (oauth_credentials id). So the MCP flow +// first lists accounts, then lists that account's spreadsheets, then its tabs. +async function listGoogleAccounts() { + const { rows } = await pool.query( + `SELECT id, account_label, health_status FROM coexistence.oauth_credentials + WHERE provider = 'google' ORDER BY created_at DESC`, + ); + return rows.map(r => ({ id: r.id, label: r.account_label, status: r.health_status })); +} + +async function searchSpreadsheets({ googleAccountId, q = '', pageSize = 50 } = {}) { + if (!googleAccountId) { + const e = new Error('Pick a Google account first (call list_google_accounts).'); e.status = 400; throw e; + } + const spreadsheets = await googleSheets.listSpreadsheets(googleAccountId, { query: q, pageSize: Math.max(1, Math.min(100, pageSize)) }); + return { spreadsheets }; +} + +function listSheetTabs(googleAccountId, spreadsheetId) { + if (!googleAccountId) { + return Promise.reject(Object.assign(new Error('Pick a Google account first (call list_google_accounts).'), { status: 400 })); + } + return googleSheets.listSheetTabs(googleAccountId, spreadsheetId).then(tabs => ({ id: spreadsheetId, tabs })); +} + +// Read actual cell values from a tab so the assistant can see the real header +// row (and a few sample rows) — needed to map an agent's Sheets logging to the +// right columns. listSheetTabs only returns metadata (names/dimensions), not +// contents, so this fills that gap. Defaults to the whole tab from A1 capped at +// maxRows; pass an A1 `range` to narrow it (e.g. "A1:Z1" for just the header). +async function readSheetValues({ googleAccountId, spreadsheetId, tab, range, maxRows } = {}) { + if (!googleAccountId) { const e = new Error('Pick a Google account first (call list_google_accounts).'); e.status = 400; throw e; } + if (!spreadsheetId) { const e = new Error('spreadsheetId is required (from search_spreadsheets).'); e.status = 400; throw e; } + if (!tab) { const e = new Error('tab is required (the tab name from list_sheet_tabs).'); e.status = 400; throw e; } + const out = await googleSheets.read({ + credentialId: googleAccountId, + spreadsheetId, + sheetName: tab, + args: { range: range || undefined, max_rows: Math.max(1, Math.min(500, parseInt(maxRows || 50, 10))) }, + }); + const rows = out.rows || []; + return { + spreadsheetId, + tab, + range: out.range, + headers: rows[0] || [], // first returned row — the column headers when the range starts at row 1 + rows: rows.slice(1), // data rows after the header + rowCount: out.rowCount, + truncated: out.truncated, + }; +} + +async function listMedia(type, name) { + const { rows } = await pool.query( + `SELECT id, name, original_name, mime_type, media_type + FROM coexistence.media_library + WHERE ($1::text IS NULL OR media_type = $1) + AND ($2::text IS NULL OR name ILIKE $2 OR original_name ILIKE $2) + ORDER BY uploaded_at DESC LIMIT 200`, + [type || null, name ? `%${name}%` : null], + ); + return rows.map(r => ({ id: r.id, name: r.name || r.original_name, mimeType: r.mime_type, mediaType: r.media_type })); +} + +async function listTemplates(waAccountId) { + const waId = waAccountId != null && waAccountId !== '' ? parseInt(waAccountId, 10) : null; + const { rows } = await pool.query( + `SELECT id, name, language, status, category, whatsapp_account_id + FROM coexistence.message_templates + WHERE ($1::bigint IS NULL OR whatsapp_account_id = $1) + ORDER BY name`, + [waId], + ); + return rows.map(r => ({ + id: r.id, name: r.name, language: r.language, status: r.status, + category: r.category, waAccountId: r.whatsapp_account_id, + })); +} + +async function getTemplate(id) { + const { rows } = await pool.query( + `SELECT id, name, language, status, category, whatsapp_account_id, + header_type, header_text, body, footer, buttons, samples + FROM coexistence.message_templates WHERE id = $1`, + [parseInt(id, 10)], + ); + if (!rows[0]) { const e = new Error('Template not found'); e.status = 404; throw e; } + const r = rows[0]; + return { + id: r.id, + name: r.name, + language: r.language, + status: r.status, + category: r.category, + waAccountId: r.whatsapp_account_id, + header: r.header_type + ? { type: r.header_type, text: r.header_text || null } + : null, + body: r.body, + footer: r.footer || null, + buttons: r.buttons || [], + samples: r.samples || {}, + }; +} + +module.exports = { + MODEL_CATALOG, PROVIDER_LABELS, CAPABILITY_KEYS, + ensureMcpTables, loadSettings, validateKey, + listWaAccounts, listModels, listGoogleAccounts, searchSpreadsheets, listSheetTabs, readSheetValues, listMedia, listTemplates, getTemplate, +}; diff --git a/backend/src/util/crypto.js b/backend/src/util/crypto.js index 69f0d6b..8eccfd4 100644 --- a/backend/src/util/crypto.js +++ b/backend/src/util/crypto.js @@ -57,4 +57,10 @@ function maskSecret(s) { return `${str.slice(0, 4)}••••••••${str.slice(-4)}`; } -module.exports = { encrypt, decrypt, maskSecret }; +// SHA-256 hex of an API key. MCP bearer keys are HASHED (not encrypted) — we +// only ever store the hash and compare; the plaintext is shown once at creation. +function hashApiKey(plain) { + return crypto.createHash('sha256').update(String(plain)).digest('hex'); +} + +module.exports = { encrypt, decrypt, maskSecret, hashApiKey }; diff --git a/db/migrations/056_agent_accept_images.sql b/db/migrations/056_agent_accept_images.sql new file mode 100644 index 0000000..ea30e61 --- /dev/null +++ b/db/migrations/056_agent_accept_images.sql @@ -0,0 +1,7 @@ +-- 056_agent_accept_images.sql +-- Per-agent toggle: when on, the agent "sees" an inbound WhatsApp image by +-- passing the image to its (vision-capable) LLM along with any caption. Mirrors +-- transcribe_audio (migration 054). Additive + idempotent. + +ALTER TABLE coexistence.agents + ADD COLUMN IF NOT EXISTS accept_images BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/db/migrations/057_mcp.sql b/db/migrations/057_mcp.sql new file mode 100644 index 0000000..8ca78fc --- /dev/null +++ b/db/migrations/057_mcp.sql @@ -0,0 +1,25 @@ +-- 057_mcp.sql +-- External MCP access: bearer API keys + a singleton settings row (master switch +-- + per-capability toggles). Mirrors services/mcpService.ensureMcpTables(). +-- Additive + idempotent. + +CREATE TABLE IF NOT EXISTS coexistence.mcp_api_keys ( + id BIGSERIAL PRIMARY KEY, + label TEXT NOT NULL, + key_prefix TEXT NOT NULL, + key_last4 TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, -- sha256(plaintext) hex + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_used_at TIMESTAMPTZ, + created_by BIGINT REFERENCES coexistence.forgecrm_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coexistence.mcp_settings ( + id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + master_enabled BOOLEAN NOT NULL DEFAULT FALSE, + capabilities JSONB NOT NULL DEFAULT '{"discovery":true,"create_agent":true,"update_agent":true,"manage_tools":true,"delete":true}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO coexistence.mcp_settings (id) VALUES (1) ON CONFLICT DO NOTHING; diff --git a/frontend/package.json b/frontend/package.json index 9d0faff..f1c9fa4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "forgecrm-frontend", - "version": "1.1.0", + "version": "1.2.0", "description": "ForgeChat frontend — WhatsApp chat viewer", "private": true, "license": "LicenseRef-Sustainable-Use-License", diff --git a/frontend/src/api.js b/frontend/src/api.js index c905bcb..aba5472 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -138,6 +138,8 @@ export const api = { update: (id, data) => req(`/chatbots/${id}`, { method: 'PUT', body: JSON.stringify(data) }), duplicate: (id) => req(`/chatbots/${id}/duplicate`, { method: 'POST' }), delete: (id) => req(`/chatbots/${id}`, { method: 'DELETE' }), + exportOne: (id) => req(`/chatbots/${id}/export`), + import: (payload) => req('/chatbots/import', { method: 'POST', body: JSON.stringify(payload) }), executions: (id, { page = 1, limit = 20, status = 'all', startDate = '', endDate = '', messageStatus = 'all' } = {}) => { const qs = new URLSearchParams({ page: String(page), limit: String(limit) }); if (status && status !== 'all') qs.set('status', status); @@ -194,6 +196,8 @@ export const api = { create: (data) => req('/agents', { method: 'POST', body: JSON.stringify(data) }), update: (id, data) => req(`/agents/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id) => req(`/agents/${id}`, { method: 'DELETE' }), + exportOne: (id) => req(`/agents/${id}/export`), + import: (payload) => req('/agents/import', { method: 'POST', body: JSON.stringify(payload) }), runs: (id, limit = 50) => req(`/agents/${id}/runs?limit=${limit}`), run: (id, runId) => req(`/agents/${id}/runs/${runId}`), addTool: (id, data) => req(`/agents/${id}/tools`, { method: 'POST', body: JSON.stringify(data) }), @@ -217,6 +221,25 @@ export const api = { }); }, }, + // Per-conversation bot control (Chats header 🤖 toggle). + agentConversation: { + status: (waNumber, contactNumber) => + req(`/agent-conversation?waNumber=${encodeURIComponent(waNumber)}&contactNumber=${encodeURIComponent(contactNumber)}`), + pause: (waNumber, contactNumber) => + req('/agent-conversation/pause', { method: 'POST', body: JSON.stringify({ waNumber, contactNumber }) }), + resume: (waNumber, contactNumber) => + req('/agent-conversation/resume', { method: 'POST', body: JSON.stringify({ waNumber, contactNumber }) }), + }, + // External MCP access — admin management of keys + capability toggles. + mcp: { + getSettings: () => req('/mcp/settings'), + updateSettings: (data) => req('/mcp/settings', { method: 'PUT', body: JSON.stringify(data) }), + listKeys: () => req('/mcp/keys'), + createKey: (label) => req('/mcp/keys', { method: 'POST', body: JSON.stringify({ label }) }), + updateKey: (id, data) => req(`/mcp/keys/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteKey: (id) => req(`/mcp/keys/${id}`, { method: 'DELETE' }), + install: () => req('/mcp/install'), + }, pipelines: { list: () => req('/pipelines'), create: (name) => req('/pipelines', { method: 'POST', body: JSON.stringify({ name }) }), diff --git a/frontend/src/components/AutomationBuilderView.jsx b/frontend/src/components/AutomationBuilderView.jsx index 4c04591..6174b0f 100644 --- a/frontend/src/components/AutomationBuilderView.jsx +++ b/frontend/src/components/AutomationBuilderView.jsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMe import { api } from "../api.js"; import AutomationExecutions from "./AutomationExecutions.jsx"; import SearchableSelect from "./SearchableSelect.jsx"; -import { maskPhone } from "../constants.js"; +import { maskPhone, downloadJson, slugifyName } from "../constants.js"; // Automation Builder — single-file React JSX (DM Sans + DM Mono, inline styles). @@ -3001,7 +3001,13 @@ const SettingsPanel = ({ node, nodes=[], edges=[], onUpdateNode=()=>{}, onDelete }; -const BuilderToolbar = ({ automationName, status, onBack, onSave, isDirty, saving, onToggleStatus, toggleBusy, onPreview, showPreview, activeTab, onTabChange, onUndo, onRedo, canUndo, canRedo, onZoomIn, onZoomOut, onFit, onAutoLayout }) => { +const DownloadIcon = (s = 13) => ( + + + +); + +const BuilderToolbar = ({ automationName, status, onBack, onSave, isDirty, saving, onToggleStatus, toggleBusy, onPreview, showPreview, activeTab, onTabChange, onUndo, onRedo, canUndo, canRedo, onZoomIn, onZoomOut, onFit, onAutoLayout, onExport }) => { const isActive = status === 'active'; const tabs = [ { key: 'editor', label: 'Editor' }, @@ -3057,6 +3063,9 @@ const BuilderToolbar = ({ automationName, status, onBack, onSave, isDirty, savin
{showPreview ? "Hide preview" : "Preview"} + {onExport && ( + Export + )} {onToggleStatus && ( { + if (!automation?.id) return; + try { + if (isDirty) await handleSave(); + const data = await api.chatbots.exportOne(automation.id); + downloadJson(`automation-${slugifyName(automation?.name)}`, data); + } catch (err) { + alert('Failed to export: ' + (err?.message || 'unknown error')); + } + }; + // "Create new template" from a node's template picker: persist the current // flow first (saved as a draft so nothing is lost), then deep-link to the // Template Builder's new-template editor (#/template-builder/new). @@ -4212,6 +4235,7 @@ const AutomationBuilderView = ({ automation, onBack, onSave, onToggleStatus, act setTransform({ x: 40 - minX * scale + (rect.width - contentW * scale) / 2, y: 30 - minY * scale + (rect.height - contentH * scale) / 2, scale: Math.max(0.3, scale * 0.9) }); }} onAutoLayout={autoLayout} + onExport={automation?.id ? handleExportAutomation : null} /> {activeTab === 'executions' ? ( diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index fc3a1f7..26d8cad 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { Search, Loader2, MoreVertical, Phone, Pencil, X, Send, Lock, Paperclip, Image as ImageIcon, FileText, Video, Music, Library, RefreshCw, CheckCircle2, AlertTriangle, Mic, Square, Trash2, User, Download, Forward, Reply, Tag, UserPlus, Check } from 'lucide-react'; +import { Search, Loader2, MoreVertical, Phone, Pencil, X, Send, Lock, Paperclip, Image as ImageIcon, FileText, Video, Music, Library, RefreshCw, CheckCircle2, AlertTriangle, Mic, Square, Trash2, User, Download, Forward, Reply, Tag, UserPlus, Check, Bot } from 'lucide-react'; import { usePolling } from '../hooks/usePolling.js'; import { useServerEvents } from '../hooks/useServerEvents.js'; import { api } from '../api.js'; @@ -102,6 +102,8 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved }) // Chat-header quick-actions: tag + assign const [assignedUserId, setAssignedUserId] = useState(null); const [assignableUsers, setAssignableUsers] = useState(null); // null = non-admin/unknown -> Assign hidden + const [agentConv, setAgentConv] = useState(null); + const [agentBusy, setAgentBusy] = useState(false); const [tagPopoverOpen, setTagPopoverOpen] = useState(false); const [assignPopoverOpen, setAssignPopoverOpen] = useState(false); const [headerSaving, setHeaderSaving] = useState(false); @@ -547,8 +549,28 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved }) setAllTags(tgs); setAssignableUsers(usrs); }); + // Is an AI agent active on this number, and is it paused for a human? + // Guarded so a missing endpoint / api shape never breaks the chat header. + api.agentConversation?.status?.(waNumber, contactNumber) + ?.then(setAgentConv, () => setAgentConv(null)); }, [waNumber, contactNumber]); + // Take over / return to bot. + const toggleAgentBot = async () => { + if (!agentConv?.hasAgent || agentBusy) return; + setAgentBusy(true); + try { + const r = agentConv.paused + ? await api.agentConversation.resume(waNumber, contactNumber) + : await api.agentConversation.pause(waNumber, contactNumber); + setAgentConv(c => ({ ...c, paused: !!r.paused })); + } catch (e) { + alert('Could not switch the bot: ' + (e.message || 'try again')); + } finally { + setAgentBusy(false); + } + }; + // Auto-scroll to bottom on first load useEffect(() => { if (data?.messages?.length && !hasScrolled && scrollRef.current) { @@ -958,6 +980,22 @@ export default function ChatWindow({ waNumber, contactNumber, onContactSaved })
)} + {/* AI agent take-over toggle — only when an agent is active on this number */} + {agentConv?.hasAgent && ( + + )} + {/* Search toggle */} + )} @@ -338,6 +394,37 @@ export default function AgentEditor({ agentId, waAccounts, user, navigate, onDon + + )} @@ -420,6 +507,81 @@ export default function AgentEditor({ agentId, waAccounts, user, navigate, onDon )} + {advancedOpen && (<> +
+ + {form.handoffEnabled && ( +
+ +
+ {bdaUsers.length === 0 &&
No team members found.
} + {bdaUsers.map(u => { + const checked = form.handoffUserIds.map(String).includes(String(u.id)); + return ( + + ); + })} +
+
+ + setForm(f => ({ ...f, handoffKeywords: e.target.value }))} + placeholder="human, agent, talk to someone" style={inputStyle} /> + +
+ )} +
+ +
+ + {form.closeSummaryEnabled && ( +
+ + setForm(f => ({ ...f, closeIdleMinutes: parseInt(e.target.value, 10) || 30 }))} + style={{ ...inputStyle, maxWidth: 140 }} /> + +
+ )} +
+ )} + {!isCreate && } setForm(f => ({ ...f, ...patch })); - const isKeyword = form.triggerMode === 'keyword'; + const mode = form.triggerMode || 'any'; + const isKeyword = mode === 'keyword'; return (
- -
- set({ triggerMode: 'any' })}>Any message + +
+ set({ triggerMode: 'any' })}>Any message + set({ triggerMode: 'new' })}>New conversations only set({ triggerMode: 'keyword' })}>Keyword
+ {mode === 'new' && ( + <> +
+ The agent answers a contact only on their first message to this number, then continues that conversation for the session window below. Existing/ongoing chats are left untouched. +
+ + + set({ triggerSessionMinutes: parseInt(e.target.value, 10) || 30 })} + style={inputStyle} + /> + + + + )} + {isKeyword && ( <> diff --git a/frontend/src/components/agents/AgentLivePreview.jsx b/frontend/src/components/agents/AgentLivePreview.jsx index 71bff93..3b2a903 100644 --- a/frontend/src/components/agents/AgentLivePreview.jsx +++ b/frontend/src/components/agents/AgentLivePreview.jsx @@ -1,9 +1,37 @@ import { useState, useRef, useEffect } from 'react'; -import { RotateCcw, AlertCircle, Mic, Square, Loader2, FileText, Music } from 'lucide-react'; +import { RotateCcw, AlertCircle, Mic, Square, Loader2, FileText, Music, ImagePlus, X } from 'lucide-react'; import { api } from '../../api.js'; import { C, FONT } from '../../constants.js'; import { PhoneFrame } from '../WhatsAppPreview.jsx'; +// Downscale + JPEG-compress an image File to keep the base64 payload well under +// the backend's JSON limit. Returns { dataUrl, mime, data (base64, no prefix) }. +function downscaleImage(file, maxDim = 1024, quality = 0.8) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error('Could not read the image.')); + reader.onload = () => { + const img = new Image(); + img.onerror = () => reject(new Error('Could not load the image.')); + img.onload = () => { + let { width, height } = img; + if (width > maxDim || height > maxDim) { + const scale = Math.min(maxDim / width, maxDim / height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + const canvas = document.createElement('canvas'); + canvas.width = width; canvas.height = height; + canvas.getContext('2d').drawImage(img, 0, 0, width, height); + const dataUrl = canvas.toDataURL('image/jpeg', quality); + resolve({ dataUrl, mime: 'image/jpeg', data: dataUrl.split(',')[1] }); + }; + img.src = reader.result; + }; + reader.readAsDataURL(file); + }); +} + /** * Live agent test, rendered inside the shared iPhone frame so it looks exactly * like a real WhatsApp chat. Typing (or recording a voice note) round-trips @@ -19,6 +47,7 @@ import { PhoneFrame } from '../WhatsAppPreview.jsx'; export default function AgentLivePreview({ agentId, headerTitle, canTest = true }) { const [messages, setMessages] = useState([]); // see message shape below const [input, setInput] = useState(''); + const [pendingImage, setPendingImage] = useState(null); // { dataUrl, mime, data } const [sending, setSending] = useState(false); const [recording, setRecording] = useState(false); const [transcribing, setTranscribing] = useState(false); @@ -26,6 +55,7 @@ export default function AgentLivePreview({ agentId, headerTitle, canTest = true const bodyRef = useRef(null); const recorderRef = useRef(null); const chunksRef = useRef([]); + const fileRef = useRef(null); useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; @@ -38,7 +68,19 @@ export default function AgentLivePreview({ agentId, headerTitle, canTest = true setSending(true); setError(''); try { - const payload = next.filter(m => m.content).map(m => ({ role: m.role, content: m.content })); + const payload = next.filter(m => m.content || m.image).map(m => { + if (m.image) { + const cap = (m.content || '').trim(); + return { + role: m.role, + content: [ + { type: 'image', mime: m.image.mime, data: m.image.data }, + { type: 'text', text: cap || 'The customer sent this image.' }, + ], + }; + } + return { role: m.role, content: m.content }; + }); const res = await api.agents.test(agentId, payload); setMessages(prev => [...prev, { role: 'assistant', @@ -56,15 +98,47 @@ export default function AgentLivePreview({ agentId, headerTitle, canTest = true const handleSend = () => { const text = input.trim(); - if (!text || sending || recording || transcribing || !canTest) return; + if ((!text && !pendingImage) || sending || recording || transcribing || !canTest) return; + const msg = { role: 'user', content: text }; + if (pendingImage) msg.image = pendingImage; setInput(''); - runTurn([...messages, { role: 'user', content: text }]); + setPendingImage(null); + runTurn([...messages, msg]); }; const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; + // ── Image attach (file picker + Ctrl+V paste) ───────────────────── + const attachImageFile = async (file) => { + if (!file || !canTest) return; + if (!file.type?.startsWith('image/')) { setError('Only image files can be attached.'); return; } + setError(''); + try { + setPendingImage(await downscaleImage(file)); + } catch (e) { + setError(e.message || 'Could not attach the image.'); + } + }; + + const handlePickImage = (e) => { + const file = e.target.files?.[0]; + if (file) attachImageFile(file); + e.target.value = ''; + }; + + useEffect(() => { + if (!canTest) return; + const onPaste = (e) => { + const item = [...(e.clipboardData?.items || [])].find(it => it.type.startsWith('image/')); + if (item) { const f = item.getAsFile(); if (f) { e.preventDefault(); attachImageFile(f); } } + }; + window.addEventListener('paste', onPaste); + return () => window.removeEventListener('paste', onPaste); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canTest, sending]); + // ── Voice note recording → transcription ────────────────────────── const startRecording = async () => { if (!canTest || sending || transcribing) return; @@ -115,10 +189,21 @@ export default function AgentLivePreview({ agentId, headerTitle, canTest = true }; const busy = sending || transcribing; - const showSend = !!input.trim() && !recording; + const showSend = (!!input.trim() || !!pendingImage) && !recording; const composer = ( -
+
+ {pendingImage && !recording && !transcribing && ( +
+ attachment + Image attached + +
+ )} +
{recording ? (
@@ -130,18 +215,30 @@ export default function AgentLivePreview({ agentId, headerTitle, canTest = true Transcribing…
) : ( - setInput(e.target.value)} - onKeyDown={handleKeyDown} - disabled={!canTest || sending} - placeholder={canTest ? 'Message' : 'Save the agent to test'} - style={{ - flex: 1, minWidth: 0, background: 'var(--c-cardBg)', borderRadius: 99, - padding: '8px 12px', fontSize: 12, color: C.text, fontFamily: FONT, - border: '1px solid var(--c-border)', outline: 'none', opacity: canTest ? 1 : 0.6, - }} - /> +
+ + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + disabled={!canTest || sending} + placeholder={canTest ? (pendingImage ? 'Add a caption (optional)' : 'Message') : 'Save the agent to test'} + style={{ + flex: 1, minWidth: 0, background: 'transparent', border: 'none', + padding: '5px 2px', fontSize: 12, color: C.text, fontFamily: FONT, outline: 'none', + }} + /> +
)} {showSend ? ( @@ -161,6 +258,7 @@ export default function AgentLivePreview({ agentId, headerTitle, canTest = true {recording ? : } )} +
); @@ -239,7 +337,15 @@ function MessageBlock({ message }) { )} - {!message.voice && message.content && ( + {message.image && ( + + sent attachment + {message.content &&
{message.content}
} +
+
+ )} + + {!message.voice && !message.image && message.content && (
{message.content}
{message.status === 'capped' && ( diff --git a/frontend/src/components/agents/AgentToolsList.jsx b/frontend/src/components/agents/AgentToolsList.jsx index b0c9de2..398ffbd 100644 --- a/frontend/src/components/agents/AgentToolsList.jsx +++ b/frontend/src/components/agents/AgentToolsList.jsx @@ -1,20 +1,24 @@ import { useState } from 'react'; -import { Plus, Trash2, FileSpreadsheet, AlertCircle, Power } from 'lucide-react'; +import { Plus, Trash2, FileSpreadsheet, AlertCircle, Power, Globe } from 'lucide-react'; import { api } from '../../api.js'; import { C, FONT, MONO } from '../../constants.js'; import GoogleSheetsToolConfig from './GoogleSheetsToolConfig.jsx'; +import HttpToolConfig from './HttpToolConfig.jsx'; /** - * Tool roster for an agent. v1 only offers Google Sheets — the registry will - * grow (Gmail, Calendar, HTTP, etc.) and this component picks up new types - * by adding cases to renderRow / the "Add tool" menu. + * Tool roster for an agent. Each tool type has an entry in TOOL_TYPES (icon + + * menu copy), a config component (rendered when adding/editing), and a ToolRow + * summary. Add a new type by extending all three. */ -// Tool types offerable from the "Add tool" menu. Only Google Sheets today; the -// registry will grow (Gmail, Calendar, HTTP, …) and each new entry shows up here. const TOOL_TYPES = [ - { type: 'google_sheets', label: 'Google Sheets', desc: 'Read, append, or update rows in a sheet.' }, + { type: 'google_sheets', label: 'Google Sheets', desc: 'Read, append, or update rows in a sheet.', + icon: FileSpreadsheet, iconColor: '#0F7A38', iconBg: '#E6F4EA' }, + { type: 'http_request', label: 'HTTP request', desc: 'Call an external API / device / webhook.', + icon: Globe, iconColor: '#2563EB', iconBg: '#E6EEFC' }, ]; +const toolMeta = (type) => TOOL_TYPES.find(t => t.type === type) || TOOL_TYPES[0]; + export default function AgentToolsList({ agentId, tools, onChange }) { const [adding, setAdding] = useState(false); // false | tool-type string const [menuOpen, setMenuOpen] = useState(false); @@ -97,47 +101,60 @@ export default function AgentToolsList({ agentId, tools, onChange }) { background: C.cardBg, border: `1px solid ${C.border}`, borderRadius: 10, boxShadow: C.shadowLg, padding: 6, minWidth: 240, }}> - {TOOL_TYPES.map(t => ( -
{ setMenuOpen(false); setAdding(t.type); }} - style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 10px', borderRadius: 7, cursor: 'pointer' }} - onMouseEnter={e => e.currentTarget.style.background = '#F5F5F0'} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> -
- -
-
-
{t.label}
-
{t.desc}
+ {TOOL_TYPES.map(t => { + const Icon = t.icon; + return ( +
{ setMenuOpen(false); setAdding(t.type); }} + style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 10px', borderRadius: 7, cursor: 'pointer' }} + onMouseEnter={e => e.currentTarget.style.background = '#F5F5F0'} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> +
+ +
+
+
{t.label}
+
{t.desc}
+
-
- ))} + ); + })}
)}
)} - {(adding === 'google_sheets' || editing) && ( -
- { setAdding(false); setEditing(null); }} - onSaved={async () => { - setAdding(false); - setEditing(null); - await onChange(); - }} - /> -
- )} + {(adding || editing) && (() => { + const activeType = editing ? editing.toolType : adding; + const ConfigComp = activeType === 'http_request' ? HttpToolConfig : GoogleSheetsToolConfig; + const close = () => { setAdding(false); setEditing(null); }; + return ( +
+ { close(); await onChange(); }} + /> +
+ ); + })()}
); } function ToolRow({ tool, busy, onToggle, onEdit, onRemove }) { const cfg = tool.config || {}; + const meta = toolMeta(tool.toolType); + const Icon = meta.icon; + const isHttp = tool.toolType === 'http_request'; + const title = isHttp + ? (cfg.label || 'HTTP request') + : `${cfg.spreadsheet_name || cfg.spreadsheet_id} · ${cfg.sheet_name}`; + const subtitle = isHttp + ? `${cfg.method || 'GET'} · ${cfg.url || ''}` + : `${(cfg.ops || []).join(' · ') || 'no ops enabled'} · acct #${cfg.google_account_id}`; return (
- +
- {cfg.spreadsheet_name || cfg.spreadsheet_id} · {cfg.sheet_name} + {title}
-
- {(cfg.ops || []).join(' · ') || 'no ops enabled'} · acct #{cfg.google_account_id} +
+ {subtitle}
+
+ + {error && ( +
+ {error} +
+ )} + + + setLabel(e.target.value)} placeholder="Control smart light" style={input} /> + + + +