feat(FN-3714): add WhatsApp chat plugin with plugin discovery and install U
Adds a WhatsApp chat plugin (`plugins/fusion-plugin-whatsapp-chat/`) as the first bundled plugin, including the plugin package with UI rendering, dashboard plugin manager integration, CLI bundling support, and plugin route runtime context. Also updates documentation to cover plugin management and ap Fusion-Task-Id: FN-3714
This commit is contained in:
5
.changeset/fn-3714-whatsapp-plugin.md
Normal file
5
.changeset/fn-3714-whatsapp-plugin.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add a first-party WhatsApp chat plugin that can be installed from built-in plugin surfaces and staged in CLI bundles.
|
||||
@@ -48,6 +48,8 @@ Setup-capable plugins (for example **Agent Browser**) expose an additional **set
|
||||
2. Review bundled entries in **Bundled Plugins** and currently installed entries.
|
||||
3. Check each plugin’s status/state in the manager.
|
||||
|
||||
First-party bundled entries include runtime plugins plus integrations like **Dependency Graph** and **WhatsApp Chat**.
|
||||
|
||||
Expected outcome: You can see what is already installed, what is bundled and available, and each plugin’s current lifecycle state.
|
||||
|
||||
### CLI
|
||||
@@ -69,6 +71,8 @@ Expected outcome: You have a terminal view of installed plugins for scripting/re
|
||||
|
||||
Expected outcome: Plugin is registered and appears with an initial state (typically `installed` then `started` when enabled/loaded).
|
||||
|
||||
> WhatsApp Chat plugin note: You must configure Meta WhatsApp Cloud credentials (`verifyToken`, `appSecret`, `accessToken`, `phoneNumberId`) and point Meta webhooks at `/api/plugins/fusion-plugin-whatsapp-chat/webhook`. Only configured/allowed senders will receive agent replies, and message IDs are deduplicated for webhook retry safety.
|
||||
|
||||
### Install from local path (dashboard)
|
||||
|
||||
1. Go to **Settings → Plugins → Fusion Plugins**.
|
||||
|
||||
@@ -169,6 +169,17 @@ describe("CLI bundle output", () => {
|
||||
expect(manifest.name?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-whatsapp-chat/ is staged with a valid manifest", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-whatsapp-chat");
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
|
||||
expect(manifest.id).toBe("fusion-plugin-whatsapp-chat");
|
||||
expect(typeof manifest.name).toBe("string");
|
||||
expect(manifest.name?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-cursor-runtime/ is staged with a valid manifest", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime");
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
|
||||
@@ -9,6 +9,7 @@ const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
|
||||
|
||||
export const BUNDLED_PLUGIN_IDS = [
|
||||
"fusion-plugin-dependency-graph",
|
||||
"fusion-plugin-whatsapp-chat",
|
||||
"fusion-plugin-hermes-runtime",
|
||||
"fusion-plugin-openclaw-runtime",
|
||||
"fusion-plugin-paperclip-runtime",
|
||||
|
||||
@@ -26,6 +26,8 @@ const llamaCppSrc = join(__dirname, "..", "pi-llama-cpp");
|
||||
const llamaCppDest = join(__dirname, "dist", "pi-llama-cpp");
|
||||
const dependencyGraphPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-dependency-graph");
|
||||
const dependencyGraphPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-dependency-graph");
|
||||
const whatsappChatPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-whatsapp-chat");
|
||||
const whatsappChatPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-whatsapp-chat");
|
||||
const dashboardClientStub = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -140,6 +142,21 @@ export default defineConfig({
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(whatsappChatPluginDest)) {
|
||||
rmSync(whatsappChatPluginDest, { recursive: true, force: true });
|
||||
}
|
||||
if (existsSync(whatsappChatPluginSrc)) {
|
||||
mkdirSync(whatsappChatPluginDest, { recursive: true });
|
||||
cpSync(join(whatsappChatPluginSrc, "manifest.json"), join(whatsappChatPluginDest, "manifest.json"));
|
||||
cpSync(join(whatsappChatPluginSrc, "package.json"), join(whatsappChatPluginDest, "package.json"));
|
||||
cpSync(join(whatsappChatPluginSrc, "src"), join(whatsappChatPluginDest, "src"), { recursive: true });
|
||||
console.log("Copied WhatsApp chat plugin to dist/plugins/fusion-plugin-whatsapp-chat/");
|
||||
} else {
|
||||
console.warn(
|
||||
`WARNING: WhatsApp chat plugin source not found at ${whatsappChatPluginSrc}; bundled auto-install will be unavailable.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Bundle each runtime plugin into a self-contained ESM file so npm/npx
|
||||
// installs can load them without the workspace `@fusion/plugin-sdk`.
|
||||
for (const pluginId of RUNTIME_PLUGIN_IDS) {
|
||||
|
||||
@@ -12,7 +12,13 @@ export {
|
||||
} from "./agent-prompts.js";
|
||||
|
||||
// ── Engine wiring (set by @fusion/engine at module load) ────────────
|
||||
export { setCreateFnAgent, getFnAgent, type AgentMessage } from "./ai-engine-loader.js";
|
||||
export {
|
||||
setCreateFnAgent,
|
||||
getFnAgent,
|
||||
setCreateAiSessionFactory,
|
||||
getCreateAiSessionFactory,
|
||||
type AgentMessage,
|
||||
} from "./ai-engine-loader.js";
|
||||
|
||||
// ── Prompt Overrides ─────────────────────────────────────────────────
|
||||
export {
|
||||
|
||||
@@ -122,6 +122,13 @@ const BUILTIN_PLUGINS: BuiltinPlugin[] = [
|
||||
category: "integration",
|
||||
path: "./plugins/fusion-plugin-dependency-graph",
|
||||
},
|
||||
{
|
||||
id: "fusion-plugin-whatsapp-chat",
|
||||
name: "WhatsApp Chat",
|
||||
description: "Connects WhatsApp Cloud webhooks to a Fusion agent conversation; requires Meta webhook and API credentials.",
|
||||
category: "integration",
|
||||
path: "./plugins/fusion-plugin-whatsapp-chat",
|
||||
},
|
||||
{
|
||||
id: BUILTIN_AGENT_BROWSER_PLUGIN_ID,
|
||||
name: "Agent Browser",
|
||||
|
||||
@@ -267,6 +267,7 @@ describe("PluginManager", () => {
|
||||
expect(screen.getByText("OpenClaw Runtime")).toBeTruthy();
|
||||
expect(screen.getByText("Droid Runtime")).toBeTruthy();
|
||||
expect(screen.getByText("Dependency Graph")).toBeTruthy();
|
||||
expect(screen.getByText("WhatsApp Chat")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders built-in agent browser metadata-only entry when uninstalled", async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore, PluginStore, PluginLoader, PluginInstallation } from "@fusion/core";
|
||||
import * as fusionCore from "@fusion/core";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { createPluginRouter } from "../plugin-routes.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
@@ -1175,12 +1176,25 @@ describe("createPluginRouter plugin setup routes", () => {
|
||||
});
|
||||
|
||||
describe("createPluginRouter plugin-defined route responses", () => {
|
||||
it("injects request-scoped taskStore and supports explicit status/body responses", async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(fusionCore, "getCreateAiSessionFactory").mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("injects request-scoped taskStore and scoped plugin settings", async () => {
|
||||
const defaultTaskStore = createMockTaskStore();
|
||||
const scopedTaskStore = createMockTaskStore({ getRootDir: vi.fn().mockReturnValue("/scoped") });
|
||||
const scopedPluginStore = createMockPluginStore({
|
||||
getPlugin: vi.fn().mockResolvedValue({ ...INSTALLED_PLUGIN, id: "demo", settings: { mode: "scoped" } }),
|
||||
});
|
||||
const scopedTaskStore = createMockTaskStore({
|
||||
getRootDir: vi.fn().mockReturnValue("/scoped"),
|
||||
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
|
||||
});
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(scopedTaskStore);
|
||||
|
||||
const pluginStore = createMockPluginStore();
|
||||
const pluginStore = createMockPluginStore({
|
||||
getPlugin: vi.fn().mockResolvedValue({ ...INSTALLED_PLUGIN, id: "demo", settings: { mode: "global" } }),
|
||||
});
|
||||
const pluginLoader = createMockPluginLoader({
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
|
||||
});
|
||||
@@ -1193,7 +1207,7 @@ describe("createPluginRouter plugin-defined route responses", () => {
|
||||
path: "/status",
|
||||
handler: vi.fn(async (_req: unknown, ctx: import("@fusion/core").PluginContext) => ({
|
||||
status: 201,
|
||||
body: { scoped: ctx.taskStore.getRootDir() },
|
||||
body: { scoped: ctx.taskStore.getRootDir(), mode: ctx.settings.mode },
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -1206,7 +1220,103 @@ describe("createPluginRouter plugin-defined route responses", () => {
|
||||
|
||||
const res = await REQUEST(app, "POST", "/plugins/demo/status?projectId=p1", { projectId: "p1" });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ scoped: "/scoped" });
|
||||
expect(res.body).toEqual({ scoped: "/scoped", mode: "scoped" });
|
||||
});
|
||||
|
||||
it("falls back to global plugin settings when scoped plugin record is unavailable", async () => {
|
||||
const scopedPluginStore = createMockPluginStore({
|
||||
getPlugin: vi.fn().mockRejectedValue(new Error("missing")),
|
||||
});
|
||||
const scopedTaskStore = createMockTaskStore({
|
||||
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
|
||||
});
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(scopedTaskStore);
|
||||
|
||||
const pluginStore = createMockPluginStore({
|
||||
getPlugin: vi.fn().mockResolvedValue({ ...INSTALLED_PLUGIN, id: "demo", settings: { mode: "global" } }),
|
||||
});
|
||||
const pluginLoader = createMockPluginLoader({
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
|
||||
});
|
||||
const pluginRunner = {
|
||||
getPluginRoutes: vi.fn().mockReturnValue([
|
||||
{
|
||||
pluginId: "demo",
|
||||
route: {
|
||||
method: "GET",
|
||||
path: "/settings",
|
||||
handler: vi.fn(async (_req: unknown, ctx: import("@fusion/core").PluginContext) => ({ mode: ctx.settings.mode })),
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner));
|
||||
|
||||
const res = await REQUEST(app, "GET", "/plugins/demo/settings?projectId=p1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ mode: "global" });
|
||||
});
|
||||
|
||||
it("includes createAiSession in plugin route context when engine has registered a factory", async () => {
|
||||
const createAiSession = vi.fn();
|
||||
vi.spyOn(fusionCore, "getCreateAiSessionFactory").mockResolvedValue(createAiSession as unknown as import("@fusion/core").CreateAiSessionFactory);
|
||||
|
||||
const pluginStore = createMockPluginStore();
|
||||
const pluginLoader = createMockPluginLoader({
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
|
||||
});
|
||||
const pluginRunner = {
|
||||
getPluginRoutes: vi.fn().mockReturnValue([
|
||||
{
|
||||
pluginId: "demo",
|
||||
route: {
|
||||
method: "GET",
|
||||
path: "/ai",
|
||||
handler: vi.fn(async (_req: unknown, ctx: import("@fusion/core").PluginContext) => ({ hasFactory: Boolean(ctx.createAiSession) })),
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner));
|
||||
|
||||
const res = await REQUEST(app, "GET", "/plugins/demo/ai");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ hasFactory: true });
|
||||
});
|
||||
|
||||
it("leaves createAiSession undefined when engine factory is unavailable", async () => {
|
||||
vi.spyOn(fusionCore, "getCreateAiSessionFactory").mockResolvedValue(undefined);
|
||||
|
||||
const pluginStore = createMockPluginStore();
|
||||
const pluginLoader = createMockPluginLoader({
|
||||
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "demo" } }),
|
||||
});
|
||||
const pluginRunner = {
|
||||
getPluginRoutes: vi.fn().mockReturnValue([
|
||||
{
|
||||
pluginId: "demo",
|
||||
route: {
|
||||
method: "GET",
|
||||
path: "/ai-none",
|
||||
handler: vi.fn(async (_req: unknown, ctx: import("@fusion/core").PluginContext) => ({ hasFactory: Boolean(ctx.createAiSession) })),
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner));
|
||||
|
||||
const res = await REQUEST(app, "GET", "/plugins/demo/ai-none");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ hasFactory: false });
|
||||
});
|
||||
|
||||
it("maps plugin-defined non-2xx status responses", async () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
PluginStore,
|
||||
PluginContext,
|
||||
} from "@fusion/core";
|
||||
import { validatePluginManifest } from "@fusion/core";
|
||||
import { getCreateAiSessionFactory, validatePluginManifest } from "@fusion/core";
|
||||
import {
|
||||
ApiError,
|
||||
badRequest,
|
||||
@@ -547,12 +547,34 @@ export function createPluginRouter(
|
||||
? (req.body as { projectId: string }).projectId
|
||||
: undefined);
|
||||
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null;
|
||||
const taskStore = scopedStore ?? defaultTaskStore ?? ({} as import("@fusion/core").TaskStore);
|
||||
|
||||
let settings: Record<string, unknown> = {};
|
||||
const scopedPluginStore = scopedStore?.getPluginStore?.();
|
||||
if (scopedPluginStore) {
|
||||
try {
|
||||
const scopedPlugin = await scopedPluginStore.getPlugin(pluginId);
|
||||
settings = scopedPlugin.settings;
|
||||
} catch {
|
||||
// Fall back to default store plugin settings when project-scoped plugin record is unavailable.
|
||||
}
|
||||
}
|
||||
if (!scopedPluginStore || Object.keys(settings).length === 0) {
|
||||
try {
|
||||
const pluginRecord = await pluginStore.getPlugin(pluginId);
|
||||
settings = pluginRecord.settings;
|
||||
} catch {
|
||||
// Keep empty settings when plugin store record isn't available.
|
||||
}
|
||||
}
|
||||
|
||||
const createAiSession = await getCreateAiSessionFactory();
|
||||
|
||||
// Create a minimal context for the handler
|
||||
const ctx: PluginContext = {
|
||||
pluginId,
|
||||
taskStore: scopedStore ?? defaultTaskStore ?? ({} as import("@fusion/core").TaskStore),
|
||||
settings: {},
|
||||
taskStore,
|
||||
settings,
|
||||
logger: {
|
||||
info: (...args: unknown[]) => console.log(`[plugin:${pluginId}]`, ...args),
|
||||
warn: (...args: unknown[]) => console.warn(`[plugin:${pluginId}]`, ...args),
|
||||
@@ -564,6 +586,7 @@ export function createPluginRouter(
|
||||
},
|
||||
},
|
||||
emitEvent: () => {},
|
||||
createAiSession,
|
||||
};
|
||||
|
||||
// Call the route handler with Express Request cast to unknown
|
||||
|
||||
32
plugins/fusion-plugin-whatsapp-chat/README.md
Normal file
32
plugins/fusion-plugin-whatsapp-chat/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# WhatsApp Chat Plugin
|
||||
|
||||
Bridges Meta WhatsApp Cloud webhooks to a Fusion AI session so you can chat with your configured Fusion assistant from WhatsApp.
|
||||
|
||||
## Settings
|
||||
|
||||
- `verifyToken`: Webhook verify token configured in Meta app settings.
|
||||
- `appSecret`: Meta app secret used for `x-hub-signature-256` validation.
|
||||
- `accessToken`: WhatsApp Cloud API token used to send replies.
|
||||
- `phoneNumberId`: WhatsApp phone number ID used for Graph API sends.
|
||||
- `graphApiVersion`: Graph API version (default `v21.0`).
|
||||
- `allowedSenders`: Optional allowlist of sender phone numbers.
|
||||
- `agentSystemPrompt`: Optional system prompt for generated replies.
|
||||
|
||||
## Webhook routes
|
||||
|
||||
- `GET /api/plugins/fusion-plugin-whatsapp-chat/webhook` verification challenge.
|
||||
- `POST /api/plugins/fusion-plugin-whatsapp-chat/webhook` signed event ingress.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Validates Meta signature against raw request body.
|
||||
- Ignores unsupported/non-text messages.
|
||||
- Deduplicates inbound WhatsApp message IDs.
|
||||
- Persists sender transcript history to keep multi-turn continuity.
|
||||
- Sends reply chunks up to WhatsApp text limits.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- 401 on webhook POST: check `appSecret` and raw-body signature header.
|
||||
- 403 on webhook GET: verify token mismatch.
|
||||
- No replies: ensure sender is in `allowedSenders` (or clear allowlist) and `accessToken`/`phoneNumberId` are valid.
|
||||
8
plugins/fusion-plugin-whatsapp-chat/manifest.json
Normal file
8
plugins/fusion-plugin-whatsapp-chat/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "fusion-plugin-whatsapp-chat",
|
||||
"name": "WhatsApp Chat",
|
||||
"version": "0.1.0",
|
||||
"description": "Bridge WhatsApp Cloud webhook messages to a Fusion agent conversation",
|
||||
"author": "Fusion Team",
|
||||
"fusionVersion": ">=0.1.0"
|
||||
}
|
||||
31
plugins/fusion-plugin-whatsapp-chat/package.json
Normal file
31
plugins/fusion-plugin-whatsapp-chat/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@fusion-plugin-examples/whatsapp-chat",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "WhatsApp Cloud webhook bridge for Fusion agents",
|
||||
"keywords": [
|
||||
"fusion-plugin",
|
||||
"whatsapp",
|
||||
"chat",
|
||||
"integration"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
191
plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts
Normal file
191
plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import plugin, { splitMessageForWhatsapp, verifyMetaSignature, webhookGetHandler, webhookPostHandler } from "../index.js";
|
||||
|
||||
function createInMemoryDb() {
|
||||
const sessions = new Map<string, string>();
|
||||
const dedupe = new Set<string>();
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn((sql: string) => ({
|
||||
get: (value: string) => {
|
||||
if (sql.includes("FROM whatsapp_chat_sessions")) {
|
||||
const history = sessions.get(value);
|
||||
return history ? { history } : undefined;
|
||||
}
|
||||
if (sql.includes("FROM whatsapp_chat_dedupe")) {
|
||||
return dedupe.has(value) ? { found: 1 } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
run: (...args: unknown[]) => {
|
||||
if (sql.includes("whatsapp_chat_sessions")) {
|
||||
sessions.set(args[0] as string, args[1] as string);
|
||||
}
|
||||
if (sql.includes("whatsapp_chat_dedupe")) {
|
||||
dedupe.add(args[0] as string);
|
||||
}
|
||||
},
|
||||
})),
|
||||
get history() {
|
||||
return sessions;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<any> = {}) {
|
||||
const db = createInMemoryDb();
|
||||
const ctx: any = {
|
||||
settings: {
|
||||
appSecret: "secret",
|
||||
verifyToken: "verify-me",
|
||||
accessToken: "token",
|
||||
phoneNumberId: "123",
|
||||
allowedSenders: ["15551234567"],
|
||||
},
|
||||
taskStore: {
|
||||
getRootDir: () => "/tmp/project",
|
||||
getPluginStore: () => ({ db }),
|
||||
},
|
||||
createAiSession: vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
state: { messages: [{ role: "assistant", content: "hello from fusion" }] },
|
||||
},
|
||||
}),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
emitEvent: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { ctx, db };
|
||||
}
|
||||
|
||||
function signBody(secret: string, body: string): string {
|
||||
return `sha256=${createHmac("sha256", secret).update(Buffer.from(body)).digest("hex")}`;
|
||||
}
|
||||
|
||||
describe("whatsapp plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("registers schema init hook", () => {
|
||||
expect(plugin.hooks?.onSchemaInit).toBeDefined();
|
||||
});
|
||||
|
||||
it("verifies GET webhook challenge", async () => {
|
||||
const { ctx } = makeCtx();
|
||||
const result = await webhookGetHandler({ query: { "hub.mode": "subscribe", "hub.verify_token": "verify-me", "hub.challenge": "abc" } }, ctx);
|
||||
expect(result).toEqual({ status: 200, body: "abc" });
|
||||
});
|
||||
|
||||
it("rejects invalid signatures", async () => {
|
||||
const { ctx } = makeCtx();
|
||||
const body = JSON.stringify({ entry: [] });
|
||||
const res = await webhookPostHandler({ rawBody: Buffer.from(body), headers: { "x-hub-signature-256": "sha256=badsig" }, body: { entry: [] } }, ctx);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("dedupes repeated inbound messages", async () => {
|
||||
const { ctx } = makeCtx();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue("") }));
|
||||
|
||||
const payload = {
|
||||
entry: [{ changes: [{ value: { messages: [{ id: "wamid.1", from: "15551234567", type: "text", text: { body: "Hi" } }] } }] }],
|
||||
};
|
||||
const raw = JSON.stringify(payload);
|
||||
const req = {
|
||||
rawBody: Buffer.from(raw),
|
||||
headers: { "x-hub-signature-256": signBody("secret", raw) },
|
||||
body: payload,
|
||||
};
|
||||
|
||||
const first = await webhookPostHandler(req, ctx);
|
||||
const second = await webhookPostHandler(req, ctx);
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
expect(ctx.createAiSession).toHaveBeenCalledTimes(1);
|
||||
expect((global.fetch as any)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves transcript continuity across turns", async () => {
|
||||
const { ctx } = makeCtx();
|
||||
const promptSpy = vi.fn().mockResolvedValue(undefined);
|
||||
ctx.createAiSession = vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
prompt: promptSpy,
|
||||
state: { messages: [{ role: "assistant", content: "reply" }] },
|
||||
},
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue("") }));
|
||||
|
||||
const makeReq = (id: string, text: string) => {
|
||||
const payload = { entry: [{ changes: [{ value: { messages: [{ id, from: "15551234567", type: "text", text: { body: text } }] } }] }] };
|
||||
const raw = JSON.stringify(payload);
|
||||
return { rawBody: Buffer.from(raw), headers: { "x-hub-signature-256": signBody("secret", raw) }, body: payload };
|
||||
};
|
||||
|
||||
await webhookPostHandler(makeReq("wamid.1", "First"), ctx);
|
||||
await webhookPostHandler(makeReq("wamid.2", "Second"), ctx);
|
||||
|
||||
const secondPrompt = promptSpy.mock.calls[1][0] as string;
|
||||
expect(secondPrompt).toContain("User: First");
|
||||
expect(secondPrompt).toContain("Assistant: reply");
|
||||
expect(secondPrompt).toContain("User: Second");
|
||||
});
|
||||
|
||||
it("formats outbound payloads and chunks long replies", async () => {
|
||||
const longReply = "a".repeat(5000);
|
||||
const { ctx } = makeCtx({
|
||||
createAiSession: vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
state: { messages: [{ role: "assistant", content: longReply }] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue("") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const payload = { entry: [{ changes: [{ value: { messages: [{ id: "wamid.1", from: "15551234567", type: "text", text: { body: "hi" } }] } }] }] };
|
||||
const raw = JSON.stringify(payload);
|
||||
|
||||
await webhookPostHandler({ rawBody: Buffer.from(raw), headers: { "x-hub-signature-256": signBody("secret", raw) }, body: payload }, ctx);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const body1 = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(body1.messaging_product).toBe("whatsapp");
|
||||
expect(body1.to).toBe("15551234567");
|
||||
});
|
||||
|
||||
it("sends fallback error response when AI/outbound flow fails", async () => {
|
||||
const { ctx } = makeCtx({
|
||||
createAiSession: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue("") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const payload = { entry: [{ changes: [{ value: { messages: [{ id: "wamid.1", from: "15551234567", type: "text", text: { body: "help" } }] } }] }] };
|
||||
const raw = JSON.stringify(payload);
|
||||
|
||||
await webhookPostHandler({ rawBody: Buffer.from(raw), headers: { "x-hub-signature-256": signBody("secret", raw) }, body: payload }, ctx);
|
||||
|
||||
const fallbackBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(fallbackBody.text.body).toContain("Sorry");
|
||||
expect(ctx.logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has signature helper coverage", () => {
|
||||
const body = Buffer.from("{}");
|
||||
const signature = signBody("secret", "{}");
|
||||
expect(verifyMetaSignature(body, signature, "secret")).toBe(true);
|
||||
expect(verifyMetaSignature(body, signature, "wrong")).toBe(false);
|
||||
});
|
||||
|
||||
it("splits oversized messages", () => {
|
||||
const chunks = splitMessageForWhatsapp("x".repeat(9000));
|
||||
expect(chunks.length).toBeGreaterThan(2);
|
||||
expect(chunks[0].length).toBeLessThanOrEqual(4096);
|
||||
});
|
||||
});
|
||||
308
plugins/fusion-plugin-whatsapp-chat/src/index.ts
Normal file
308
plugins/fusion-plugin-whatsapp-chat/src/index.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse, PluginSettingSchema } from "@fusion/plugin-sdk";
|
||||
|
||||
const MAX_WHATSAPP_MESSAGE_CHARS = 4096;
|
||||
|
||||
const settingsSchema: Record<string, PluginSettingSchema> = {
|
||||
verifyToken: { type: "password", label: "Verify Token", required: true },
|
||||
appSecret: { type: "password", label: "App Secret", required: true },
|
||||
accessToken: { type: "password", label: "Access Token", required: true },
|
||||
phoneNumberId: { type: "string", label: "Phone Number ID", required: true },
|
||||
graphApiVersion: { type: "string", label: "Graph API Version", defaultValue: "v21.0" },
|
||||
allowedSenders: { type: "array", label: "Allowed WhatsApp Senders", itemType: "string" },
|
||||
agentSystemPrompt: { type: "string", label: "Agent System Prompt", multiline: true, defaultValue: "You are a helpful assistant replying in WhatsApp chats." },
|
||||
};
|
||||
|
||||
type ChatTurn = { role: "user" | "assistant"; text: string; createdAt: string };
|
||||
|
||||
type PluginDb = {
|
||||
exec(sql: string): void;
|
||||
prepare(sql: string): {
|
||||
get(...args: unknown[]): unknown;
|
||||
run(...args: unknown[]): unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type IncomingMessage = { id: string; from: string; text: string };
|
||||
|
||||
type PluginRequest = {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
query?: Record<string, string | string[] | undefined>;
|
||||
body?: unknown;
|
||||
rawBody?: Buffer;
|
||||
};
|
||||
|
||||
function getSettingString(settings: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = settings[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getAllowedSenders(settings: Record<string, unknown>): Set<string> {
|
||||
const senders = settings.allowedSenders;
|
||||
if (!Array.isArray(senders)) return new Set<string>();
|
||||
return new Set(senders.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()));
|
||||
}
|
||||
|
||||
function splitMessageForWhatsapp(text: string): string[] {
|
||||
if (text.length <= MAX_WHATSAPP_MESSAGE_CHARS) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= MAX_WHATSAPP_MESSAGE_CHARS) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
const candidate = remaining.slice(0, MAX_WHATSAPP_MESSAGE_CHARS);
|
||||
const splitAt = Math.max(candidate.lastIndexOf("\n"), candidate.lastIndexOf(" "));
|
||||
const breakpoint = splitAt > 0 ? splitAt : MAX_WHATSAPP_MESSAGE_CHARS;
|
||||
chunks.push(remaining.slice(0, breakpoint).trim());
|
||||
remaining = remaining.slice(breakpoint).trimStart();
|
||||
}
|
||||
return chunks.filter(Boolean);
|
||||
}
|
||||
|
||||
function verifyMetaSignature(rawBody: Buffer, signatureHeader: string | undefined, appSecret: string): boolean {
|
||||
if (!signatureHeader?.startsWith("sha256=")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = createHmac("sha256", appSecret).update(rawBody).digest("hex");
|
||||
const provided = signatureHeader.slice("sha256=".length);
|
||||
const expectedBuf = Buffer.from(expected, "hex");
|
||||
const providedBuf = Buffer.from(provided, "hex");
|
||||
if (expectedBuf.length !== providedBuf.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(expectedBuf, providedBuf);
|
||||
}
|
||||
|
||||
function extractIncomingMessages(payload: unknown): IncomingMessage[] {
|
||||
const body = payload as {
|
||||
entry?: Array<{ changes?: Array<{ value?: { messages?: Array<{ id?: string; from?: string; text?: { body?: string }; type?: string }> } }> }>;
|
||||
};
|
||||
|
||||
const messages: IncomingMessage[] = [];
|
||||
for (const entry of body.entry ?? []) {
|
||||
for (const change of entry.changes ?? []) {
|
||||
for (const message of change.value?.messages ?? []) {
|
||||
if (message.type !== "text") continue;
|
||||
if (!message.id || !message.from || !message.text?.body?.trim()) continue;
|
||||
messages.push({ id: message.id, from: message.from, text: message.text.body.trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function ensureSchema(db: PluginDb): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS whatsapp_chat_sessions (
|
||||
sender TEXT PRIMARY KEY,
|
||||
history TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS whatsapp_chat_dedupe (
|
||||
messageId TEXT PRIMARY KEY,
|
||||
sender TEXT NOT NULL,
|
||||
receivedAt TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function loadHistory(db: PluginDb, sender: string): ChatTurn[] {
|
||||
const row = db.prepare("SELECT history FROM whatsapp_chat_sessions WHERE sender = ?").get(sender) as { history: string } | undefined;
|
||||
if (!row) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.history) as ChatTurn[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(db: PluginDb, sender: string, history: ChatTurn[]): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO whatsapp_chat_sessions(sender, history, updatedAt)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(sender) DO UPDATE SET history = excluded.history, updatedAt = excluded.updatedAt
|
||||
`).run(sender, JSON.stringify(history), now);
|
||||
}
|
||||
|
||||
function wasProcessed(db: PluginDb, messageId: string): boolean {
|
||||
const row = db.prepare("SELECT 1 as found FROM whatsapp_chat_dedupe WHERE messageId = ?").get(messageId) as { found: number } | undefined;
|
||||
return Boolean(row?.found);
|
||||
}
|
||||
|
||||
function markProcessed(db: PluginDb, messageId: string, sender: string): void {
|
||||
db.prepare("INSERT INTO whatsapp_chat_dedupe(messageId, sender, receivedAt) VALUES(?, ?, ?)").run(messageId, sender, new Date().toISOString());
|
||||
}
|
||||
|
||||
async function sendWhatsappText(ctx: PluginContext, to: string, text: string): Promise<void> {
|
||||
const accessToken = getSettingString(ctx.settings, "accessToken");
|
||||
const phoneNumberId = getSettingString(ctx.settings, "phoneNumberId");
|
||||
const graphApiVersion = getSettingString(ctx.settings, "graphApiVersion") ?? "v21.0";
|
||||
if (!accessToken || !phoneNumberId) {
|
||||
throw new Error("WhatsApp plugin missing accessToken or phoneNumberId settings");
|
||||
}
|
||||
|
||||
for (const chunk of splitMessageForWhatsapp(text)) {
|
||||
const response = await fetch(`https://graph.facebook.com/${graphApiVersion}/${phoneNumberId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: "whatsapp",
|
||||
to,
|
||||
type: "text",
|
||||
text: { body: chunk },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`WhatsApp Graph API request failed (${response.status}): ${errorText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReply(ctx: PluginContext, sender: string, text: string, history: ChatTurn[]): Promise<string> {
|
||||
if (!ctx.createAiSession) {
|
||||
throw new Error("AI session factory unavailable: engine not registered");
|
||||
}
|
||||
|
||||
const systemPrompt = getSettingString(ctx.settings, "agentSystemPrompt") ?? "You are a helpful assistant replying in WhatsApp chats.";
|
||||
const sessionResult = await ctx.createAiSession({
|
||||
cwd: ctx.taskStore.getRootDir(),
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
});
|
||||
|
||||
const promptLines = [
|
||||
"Continue this WhatsApp conversation.",
|
||||
...history.map((turn) => `${turn.role === "user" ? "User" : "Assistant"}: ${turn.text}`),
|
||||
`User: ${text}`,
|
||||
"Assistant:",
|
||||
];
|
||||
|
||||
await sessionResult.session.prompt(promptLines.join("\n"));
|
||||
const assistantMessages = sessionResult.session.state.messages.filter((message) => message.role === "assistant");
|
||||
const latest = assistantMessages[assistantMessages.length - 1];
|
||||
const content = latest?.content;
|
||||
if (typeof content === "string" && content.trim()) return content.trim();
|
||||
if (Array.isArray(content)) {
|
||||
const textParts = content
|
||||
.map((part) => (part && typeof part === "object" && "text" in part && typeof (part as { text?: unknown }).text === "string") ? (part as { text: string }).text : "")
|
||||
.filter(Boolean);
|
||||
if (textParts.length > 0) return textParts.join("\n").trim();
|
||||
}
|
||||
|
||||
throw new Error("AI session returned no assistant text");
|
||||
}
|
||||
|
||||
async function getDbFromTaskStore(ctx: PluginContext): Promise<PluginDb> {
|
||||
const pluginStore = ctx.taskStore.getPluginStore();
|
||||
const db = (pluginStore as unknown as { db?: PluginDb }).db;
|
||||
if (!db) {
|
||||
throw new Error("Plugin database unavailable");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
async function webhookGetHandler(req: PluginRequest, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
const verifyToken = getSettingString(ctx.settings, "verifyToken");
|
||||
const mode = req.query?.["hub.mode"];
|
||||
const challenge = req.query?.["hub.challenge"];
|
||||
const token = req.query?.["hub.verify_token"];
|
||||
|
||||
if (mode === "subscribe" && verifyToken && token === verifyToken && typeof challenge === "string") {
|
||||
return { status: 200, body: challenge };
|
||||
}
|
||||
|
||||
return { status: 403, body: { error: "Verification failed" } };
|
||||
}
|
||||
|
||||
async function webhookPostHandler(req: PluginRequest, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
const appSecret = getSettingString(ctx.settings, "appSecret");
|
||||
if (!appSecret) {
|
||||
return { status: 500, body: { error: "appSecret is not configured" } };
|
||||
}
|
||||
|
||||
const signatureHeader = (req.headers?.["x-hub-signature-256"] ?? req.headers?.["X-Hub-Signature-256"]) as string | undefined;
|
||||
if (!req.rawBody || !verifyMetaSignature(req.rawBody, signatureHeader, appSecret)) {
|
||||
return { status: 401, body: { error: "Invalid webhook signature" } };
|
||||
}
|
||||
|
||||
const db = await getDbFromTaskStore(ctx);
|
||||
const allowedSenders = getAllowedSenders(ctx.settings);
|
||||
const inboundMessages = extractIncomingMessages(req.body);
|
||||
|
||||
for (const inbound of inboundMessages) {
|
||||
if (wasProcessed(db, inbound.id)) {
|
||||
continue;
|
||||
}
|
||||
markProcessed(db, inbound.id, inbound.from);
|
||||
|
||||
if (allowedSenders.size > 0 && !allowedSenders.has(inbound.from)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const history = loadHistory(db, inbound.from);
|
||||
const reply = await generateReply(ctx, inbound.from, inbound.text, history);
|
||||
const now = new Date().toISOString();
|
||||
const nextHistory: ChatTurn[] = [
|
||||
...history,
|
||||
{ role: "user" as const, text: inbound.text, createdAt: now },
|
||||
{ role: "assistant" as const, text: reply, createdAt: now },
|
||||
].slice(-40);
|
||||
saveHistory(db, inbound.from, nextHistory);
|
||||
await sendWhatsappText(ctx, inbound.from, reply);
|
||||
} catch (error) {
|
||||
ctx.logger.error("WhatsApp chat processing failed", error);
|
||||
await sendWhatsappText(ctx, inbound.from, "Sorry, I hit an internal error while processing that message.");
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 200, body: { processed: inboundMessages.length } };
|
||||
}
|
||||
|
||||
const routes: PluginRouteDefinition[] = [
|
||||
{ method: "GET", path: "/webhook", handler: webhookGetHandler as unknown as PluginRouteDefinition["handler"] },
|
||||
{ method: "POST", path: "/webhook", handler: webhookPostHandler as unknown as PluginRouteDefinition["handler"] },
|
||||
];
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-whatsapp-chat",
|
||||
name: "WhatsApp Chat",
|
||||
version: "0.1.0",
|
||||
description: "Bridge WhatsApp Cloud webhook messages to a Fusion agent conversation",
|
||||
author: "Fusion Team",
|
||||
settingsSchema,
|
||||
},
|
||||
state: "installed",
|
||||
routes,
|
||||
hooks: {
|
||||
onSchemaInit: (db) => {
|
||||
ensureSchema(db);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
export {
|
||||
extractIncomingMessages,
|
||||
generateReply,
|
||||
splitMessageForWhatsapp,
|
||||
verifyMetaSignature,
|
||||
webhookGetHandler,
|
||||
webhookPostHandler,
|
||||
};
|
||||
8
plugins/fusion-plugin-whatsapp-chat/tsconfig.json
Normal file
8
plugins/fusion-plugin-whatsapp-chat/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
8
plugins/fusion-plugin-whatsapp-chat/vitest.config.ts
Normal file
8
plugins/fusion-plugin-whatsapp-chat/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -752,9 +752,6 @@ importers:
|
||||
'@fusion/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core
|
||||
'@fusion/engine':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/engine
|
||||
'@fusion/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
@@ -772,6 +769,22 @@ importers:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
plugins/fusion-plugin-whatsapp-chat:
|
||||
dependencies:
|
||||
'@fusion/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.5.2
|
||||
version: 25.5.2
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
packages:
|
||||
|
||||
7zip-bin@5.2.0:
|
||||
|
||||
@@ -8,4 +8,5 @@ packages:
|
||||
- "plugins/fusion-plugin-droid-runtime"
|
||||
- "plugins/fusion-plugin-cursor-runtime"
|
||||
- "plugins/fusion-plugin-agent-browser"
|
||||
- "plugins/fusion-plugin-whatsapp-chat"
|
||||
- "plugins/fusion-plugin-roadmap"
|
||||
|
||||
Reference in New Issue
Block a user