Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/chat-adapter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tsconfig.tsbuildinfo
81 changes: 81 additions & 0 deletions packages/chat-adapter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# @chat-adapter/novu

A [Chat SDK](https://www.npmjs.com/package/chat) platform adapter that exposes **all of Novu's
normalized chat channels — Slack, WhatsApp, Microsoft Teams, Telegram, and Email — as a single
platform**. Novu does the per-channel normalization (one `Conversation` + `Subscriber` + history)
and calls your bridge; your Chat SDK app is the brain. One handler set serves every channel with no
per-channel code.

```
End-user channels ──platform webhooks──▶ NOVU (normalize) ──POST AgentBridgeRequest (HMAC)──▶
your Chat SDK app (@chat-adapter/novu) ──AgentReplyPayload → POST /v1/agents/:id/reply──▶ NOVU ──▶ channel
```

## Install

```bash
npm install @chat-adapter/novu chat
```

`chat` is a peer dependency. `react` is an optional peer (only needed for JSX cards).

## Usage

```ts
import { Chat } from 'chat';
import { createNovuAdapter, createMemoryState, getNovuContext } from '@chat-adapter/novu';

const novu = createNovuAdapter({
apiKey: process.env.NOVU_SECRET_KEY!, // Authorization for reply POSTs
agentIdentifier: 'support-agent',
bridgeSecret: process.env.NOVU_SECRET_KEY!, // verifies inbound HMAC
// apiBaseUrl: 'https://eu.api.novu.co', // defaults to https://api.novu.co
// bridgeUrl: 'https://my-app.com/api/novu',// optional boot-time bridge registration
});

const chat = new Chat({
userName: 'support',
adapters: { novu },
state: createMemoryState(), // zero-deps, single-instance; use a shared state adapter for multi-instance
});

chat.onNewMention(async (thread, message) => {
await thread.post(`Hi! You said: ${message.text}`);
});

chat.onSubscribedMessage(async (thread, message) => {
await thread.post(`echo: ${message.text}`);

// Opt-in, Novu-only capabilities:
const ctx = getNovuContext(thread);
if (ctx.platform === 'whatsapp') {
await ctx.trigger('escalation-email', { payload: { text: message.text } });
}
});

await chat.initialize();
```

Wire the webhook route to `novu.handleWebhook(request)` (any Web `Request`/`Response` runtime —
Next.js route handlers, Hono, etc.).

## Behavior & v1 scope

- **In:** messages, button actions, reactions, full Novu history, subscriber identity, platform
awareness, dedup (per `deliveryId`).
- **Out:** markdown, cards, edits (in-place), reaction adds, edit-based streaming (via the chat
package's built-in cadence), plus opt-in `getNovuContext().trigger / setMetadata / resolve`.
- **Routing:** an ongoing conversation (`messageCount > 1` or non-empty history) is pre-subscribed →
`onSubscribedMessage`; a brand-new conversation routes to `onNewMention` (channels) or
`onDirectMessage` (DMs, via `platformContext.isDM`).
- **Security:** the inbound HMAC (`novu-signature`) is verified over the raw body; the reply URL is
**derived from your config** and the request's `replyUrl` is ignored, so a forged request can never
exfiltrate your `apiKey`.
- **Not implemented in v1:** `deleteMessage`, modals, outbound-initiated DMs (`openDM`), code-driven
channel provisioning, Novu-side turn serialization.

## State

`createMemoryState()` is in-process and safe for a single instance. For horizontally-scaled or
serverless bridges with more than one warm instance, pass a shared state adapter
(e.g. `@chat-adapter/state-ioredis`) to `new Chat({ state })` so locks and dedup are correct.
43 changes: 43 additions & 0 deletions packages/chat-adapter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@chat-adapter/novu",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "Novu adapter for the Chat SDK — expose all of Novu's normalized chat channels (Slack, WhatsApp, Teams, Telegram, Email) as a single Chat SDK platform adapter",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/"
],
"scripts": {
"afterinstall": "pnpm build",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
"prebuild": "rimraf dist tsconfig.tsbuildinfo",
"build": "tsc -p tsconfig.json",
"watch:build": "tsc -p tsconfig.json -w",
"test": "vitest run",
"test:watch": "vitest",
"check": "biome check .",
"check:fix": "biome check --write ."
},
"peerDependencies": {
"chat": ">=4.30.0",
"react": ">=18.0.0 || >=19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"devDependencies": {
"@types/react": "^19.0.0",
"chat": "4.30.0",
"rimraf": "~3.0.2",
"typescript": "5.6.2",
"vitest": "^1.2.1"
},
"nx": {
"tags": [
"type:package"
]
}
}
28 changes: 28 additions & 0 deletions packages/chat-adapter/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@chat-adapter/novu",
"sourceRoot": "packages/chat-adapter/src",
"projectType": "library",
"targets": {
"build": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter @chat-adapter/novu run build",
"cwd": "{workspaceRoot}"
}
},
"test": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter @chat-adapter/novu run test",
"cwd": "{workspaceRoot}"
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "npx biome lint packages/chat-adapter"
}
}
},
"tags": ["type:package"]
}
159 changes: 159 additions & 0 deletions packages/chat-adapter/src/adapter.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { createHmac } from 'node:crypto';
import { Chat } from 'chat';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createNovuAdapter } from './index.js';
import { createMemoryState } from './state-memory.js';
import type { AgentBridgeRequest } from './types.js';

const BRIDGE_SECRET = 'bridge-secret';
const API_KEY = 'api-key';

function sign(body: string, secret = BRIDGE_SECRET): string {
const ts = Date.now();
const hmac = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');

return `t=${ts},v1=${hmac}`;
}

function bridgeRequest(overrides: Partial<AgentBridgeRequest> = {}): AgentBridgeRequest {
return {
version: 1,
timestamp: new Date().toISOString(),
deliveryId: `d-${Math.random()}`,
event: 'onMessage',
agentId: 'support-agent',
replyUrl: 'https://attacker.example.com/steal',
conversationId: 'conv-1',
integrationIdentifier: 'slack-prod',
action: null,
message: {
text: 'hello',
platformMessageId: 'pm-1',
author: { userId: 'u1', userName: 'alice', fullName: 'Alice', isBot: false },
timestamp: new Date().toISOString(),
},
reaction: null,
conversation: {
identifier: 'conv-1',
status: 'open',
metadata: {},
messageCount: 2,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
},
subscriber: { subscriberId: 'sub-1', firstName: 'Alice' },
history: [{ role: 'user', type: 'text', content: 'earlier', createdAt: new Date().toISOString() }],
platform: 'slack',
platformContext: { threadId: 'pm-1', channelId: 'C1', isDM: false },
...overrides,
};
}

async function deliver(adapter: ReturnType<typeof createNovuAdapter>, req: AgentBridgeRequest): Promise<Response> {
const body = JSON.stringify(req);
const request = new Request('https://bridge.example.com/api/novu', {
method: 'POST',
headers: { 'content-type': 'application/json', 'novu-signature': sign(body) },
body,
});

return adapter.handleWebhook(request);
}

describe('Novu adapter end-to-end', () => {
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
fetchMock = vi.fn(
async () => new Response(JSON.stringify({ messageId: 'm-1', platformThreadId: 't-1' }), { status: 200 })
);
});

function buildChat() {
const adapter = createNovuAdapter({
apiKey: API_KEY,
agentIdentifier: 'support-agent',
bridgeSecret: BRIDGE_SECRET,
fetch: fetchMock as unknown as typeof fetch,
});
const chat = new Chat({ userName: 'support', adapters: { novu: adapter }, state: createMemoryState() });

return { adapter, chat };
}

it('routes an ongoing conversation to onSubscribedMessage and replies via the derived URL', async () => {
const { adapter, chat } = buildChat();
const seen: string[] = [];
chat.onSubscribedMessage(async (thread, message) => {
seen.push(message.text);
await thread.post(`echo: ${message.text}`);
});
await chat.initialize();

const res = await deliver(adapter, bridgeRequest());
expect(res.status).toBe(200);
expect(seen).toEqual(['hello']);

expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
// Reply went to the derived URL, NOT the attacker-controlled replyUrl in the request.
expect(url).toBe('https://api.novu.co/v1/agents/support-agent/reply');
expect((init.headers as Record<string, string>).authorization).toBe(`ApiKey ${API_KEY}`);
expect(JSON.parse(init.body as string)).toMatchObject({
conversationId: 'conv-1',
integrationIdentifier: 'slack-prod',
reply: { markdown: 'echo: hello' },
});
});

it('routes a brand-new channel conversation to onNewMention', async () => {
const { adapter, chat } = buildChat();
const mentions: string[] = [];
chat.onNewMention(async (_thread, message) => {
mentions.push(message.text);
});
chat.onSubscribedMessage(async () => {
throw new Error('should not be subscribed on first message');
});
await chat.initialize();

await deliver(
adapter,
bridgeRequest({ conversation: { ...bridgeRequest().conversation, messageCount: 1 }, history: [] })
);

expect(mentions).toEqual(['hello']);
});

it('rejects an invalid signature with 401 and does not dispatch', async () => {
const { adapter, chat } = buildChat();
const handler = vi.fn();
chat.onSubscribedMessage(handler);
await chat.initialize();

const body = JSON.stringify(bridgeRequest());
const request = new Request('https://bridge.example.com/api/novu', {
method: 'POST',
headers: { 'content-type': 'application/json', 'novu-signature': sign(body, 'wrong-secret') },
body,
});
const res = await adapter.handleWebhook(request);

expect(res.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});

it('dedupes a replayed deliveryId (same delivery processed once)', async () => {
const { adapter, chat } = buildChat();
const handler = vi.fn();
chat.onSubscribedMessage(handler);
await chat.initialize();

const req = bridgeRequest({ deliveryId: 'fixed-delivery' });
await deliver(adapter, req);
await deliver(adapter, req);

expect(handler).toHaveBeenCalledTimes(1);
});
});
Loading
Loading