Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
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
123 changes: 123 additions & 0 deletions packages/chat-adapter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# @novu/chat-sdk-adapter

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 (@novu/chat-sdk-adapter) ──AgentReplyPayload → POST /v1/agents/:id/reply──▶ NOVU ──▶ channel
```

## Install

```bash
npm install @novu/chat-sdk-adapter chat @chat-adapter/state-memory
```

`chat` is a peer dependency. `react` is an optional peer (only needed for JSX cards).
A `StateAdapter` is required by the Chat SDK — use the official `@chat-adapter/state-memory`
for local/single-instance, or a shared adapter (`@chat-adapter/state-redis`,
`@chat-adapter/state-ioredis`, `@chat-adapter/state-pg`) for production.

## Usage

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

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(), // official @chat-adapter/state-memory; single-instance only
});

chat.onNewMention(async (thread, message) => {
if (thread.isDM) {
await thread.post(`Hi (DM)! You said: ${message.text}`);
} else {
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);

// Full Novu subscriber (email, phone, avatar, locale, custom `data`):
const subscriber = await ctx.getSubscriber();

// Canonical transcript for LLM context:
const history = await ctx.getHistory();
const ticketId = await ctx.getMetadata('ticketId');

if (subscriber?.data?.plan === 'enterprise') {
await thread.post('Priority support enabled.');
}

if (ctx.platform === 'whatsapp') {
await ctx.trigger('escalation-email', { payload: { text: message.text } });
}
});

// Post markdown with a file attachment:
await thread.post({
markdown: 'See attached report',
files: [{ filename: 'report.txt', data: Buffer.from('...'), mimeType: 'text/plain' }],
});

// Portable, SDK-native identity lookup — works for any Chat SDK code,
// returns the standard `UserInfo` shape (id, name, email, avatarUrl):
const user = await novu.getUser(message.author.userId);

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`).
- **Subscriber:** portable identity rides each message's `author`; the SDK-native
`adapter.getUser(userId)` maps the subscriber to `UserInfo` (id/name/email/avatar); and the full
rich profile (`phone`, `locale`, custom `data`) is available via
`getNovuContext(thread).getSubscriber()`.
- **Conversation & history:** `getNovuContext(thread).getConversation()` for status/metadata;
`getHistory()` for the canonical Novu transcript (best for LLM context); `getMetadata(key)` to
read conversation metadata; `getEmailContext()` on email threads.
- **Out:** markdown, cards, **files** (via postable `files`/`attachments`), edits (in-place), reaction adds, edit-based streaming (via the chat
package's built-in cadence), plus opt-in `getNovuContext().trigger / setMetadata / clearMetadata / resolve`.
- **Routing (recommended):** do **not** register `onDirectMessage` — use `onNewMention` for the
first message (`thread.isDM` for DM vs channel) and `onSubscribedMessage` for all follow-ups.
The adapter pre-subscribes when `messageCount > 1` (Novu history always includes the
current message, so history length is not used). If you register
`onDirectMessage`, Chat SDK sends **every** DM there and `onSubscribedMessage` never runs for DMs.
- **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

This adapter does not ship its own state layer — it relies on the Chat SDK's standard
`StateAdapter`. Use the official memory adapter `@chat-adapter/state-memory`
(`createMemoryState()`), which 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 (`@chat-adapter/state-redis`, `@chat-adapter/state-ioredis`, or
`@chat-adapter/state-pg`) 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": "@novu/chat-sdk-adapter",
"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": {
"@chat-adapter/state-memory": "4.30.0",
"@types/react": "^19.0.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": "@novu/chat-sdk-adapter",
"sourceRoot": "packages/chat-adapter/src",
"projectType": "library",
"targets": {
"build": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter @novu/chat-sdk-adapter run build",
"cwd": "{workspaceRoot}"
}
},
"test": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm --filter @novu/chat-sdk-adapter run test",
"cwd": "{workspaceRoot}"
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "npx biome lint packages/chat-adapter"
}
}
},
"tags": ["type:package"]
}
Loading
Loading