Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
121 changes: 120 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ const { accountId, conversationId } = adapter.decodeThreadId(threadId);
| Feature | Supported | Notes |
|---------|-----------|-------|
| Send messages | Yes | Text messages across all platforms |
| Rich messages (cards) | Yes | Buttons and templates on FB, IG, Telegram, WhatsApp |
| Rich messages (cards) | Yes | Buttons + templates on FB, IG, Telegram, WhatsApp; card `Select`/`RadioSelect` → WhatsApp interactive **list** |
| WhatsApp rich messages | Yes | Interactive lists, cta_url buttons, flows, location-request + voice-call buttons, location pins, contact cards, approved templates, quoted replies — via `ZernioApiClient` ([see below](#whatsapp-rich-messages)) |
| Inbound interactive replies | Yes | Button taps, list selections, and flow responses on `message.raw.metadata` ([see below](#inbound-interactive-replies)) |
| Open conversation by recipient | Yes | `openDM` / `openConversation` — cold-start a chat from a phone number ([see below](#opening-conversations)) |
| Edit messages | Partial | Telegram only |
| Delete messages | Partial | Telegram, X (full delete); Bluesky, Reddit (self-only) |
| Send reactions | Partial | Telegram and WhatsApp (add/remove emoji) |
Expand All @@ -149,6 +152,9 @@ const { accountId, conversationId } = adapter.decodeThreadId(threadId);
|---------|----|----|----------|----------|---|---------|--------|
| Send text | Y | Y | Y | Y | Y | Y | Y |
| Buttons | Y | Y | Y | Y | - | - | - |
| Lists | - | - | - | Y | - | - | - |
| Location / Contacts | - | - | - | Y | - | - | - |
| Templates / Flows | - | - | - | Y | - | - | - |
| Typing | Y | - | Y | Y | - | - | - |
| Delete | - | - | Y | - | Y | Self | Self |
| Reactions | - | - | Y | Y | - | - | - |
Expand Down Expand Up @@ -180,6 +186,108 @@ await thread.post(
// Falls back to text on X/Bluesky/Reddit
```

A card `Select` or `RadioSelect` is mapped to a WhatsApp **interactive list** (it can't coexist with reply buttons, so the list takes precedence):

```typescript
import { Card, Actions, Select, SelectOption } from "chat";

await thread.post(
Card({
title: "Pick a plan",
children: [
Actions([
Select({
id: "plan",
placeholder: "Choose plan", // becomes the list's open button (max 20 chars)
options: [
SelectOption({ label: "Basic", value: "basic", description: "$10/mo" }),
SelectOption({ label: "Pro", value: "pro" }),
],
}),
]),
],
})
);
```

## WhatsApp Rich Messages

WhatsApp-only message types that don't map to a Chat SDK card are sent through the exported [`ZernioApiClient`](#api-client), used alongside the adapter. Decode a thread id to get the `accountId` + `conversationId`:

```typescript
import { ZernioApiClient } from "@zernio/chat-sdk-adapter";

const client = new ZernioApiClient(process.env.ZERNIO_API_KEY!, "https://zernio.com/api");
const { accountId, conversationId } = adapter.decodeThreadId(threadId);

// Reply buttons / list / cta_url / flow / location-request / voice-call button
await client.sendInteractive(conversationId, accountId, {
type: "cta_url",
body: { text: "View your order" },
action: { name: "cta_url", parameters: { display_text: "Open", url: "https://example.com/o/123" } },
});

// Location pin
await client.sendLocation(conversationId, accountId, {
latitude: 41.3874, longitude: 2.1686, name: "HQ", address: "Barcelona",
});

// Contact cards (vCard)
await client.sendContacts(conversationId, accountId, [
{ name: { formatted_name: "Ana Ruiz" }, phones: [{ phone: "+34600000000", type: "WORK" }] },
]);

// Approved template (re-opens the 24h window)
await client.sendTemplate(conversationId, accountId, { name: "order_update", language: "en_US" });

// Quote / reply to a specific message
await client.reply(conversationId, accountId, "wamid.HBg...", "Thanks, on it!");
```

`sendInteractive` accepts the full WhatsApp interactive union: `button`, `list`, `cta_url`, `flow`, `location_request_message`, and `voice_call`. Pass `{ replyTo }` as the 4th arg to quote a message.

## Inbound Interactive Replies

When a user taps a reply button, picks a list row, or submits a WhatsApp Flow, it arrives as a normal `onNewMessage` whose interactive context is on `message.raw.metadata`:

```typescript
bot.onNewMessage(/.*/, async (thread, message) => {
const meta = (message.raw as any).metadata;
if (meta?.interactiveType === "button_reply" || meta?.interactiveType === "list_reply") {
// The id you set when sending the button/row
await thread.post(`You picked: ${meta.interactiveId}`);
}
if (meta?.interactiveType === "nfm_reply") {
const form = meta.flowResponseData; // parsed Flow response
}
// meta.referral -> Click-to-WhatsApp ad attribution (when the chat started from an ad)
// meta.quotedMessageId -> the message this one replies to
});
```

## Opening Conversations

Start a chat with someone who hasn't messaged you yet.

`openDM(userId)` is the standard Chat SDK method. Because one Zernio account = one channel, namespace the recipient as `"{accountId}:{recipient}"` (a phone/E.164 for WhatsApp). It's resolution-only — no network call — and the first `post()` opens the thread:

```typescript
const thread = await bot.openDM("507f1f77bcf86cd799439011:16505551234");
await thread.post("Hi!"); // WhatsApp: the first message must be a template (see below)
```

For WhatsApp you must open with an approved template (the 24h-window rule). `openConversation` sends it and returns the thread id in one step:

```typescript
const threadId = await adapter.openConversation({
accountId: "507f1f77bcf86cd799439011",
to: "16505551234",
template: { name: "welcome", language: "en_US", params: ["Ana"] },
});
// Non-WhatsApp platforms can open with a plain message instead:
// await adapter.openConversation({ accountId, to, message: "Hi!" });
```

## AI Streaming

Stream AI responses with the post+edit pattern (works best on Telegram). `thread.post()` accepts an `AsyncIterable<string>`, so you can pass the `textStream` from `streamText` directly:
Expand Down Expand Up @@ -272,6 +380,17 @@ await client.addReaction(conversationId, messageId, accountId, "👍");

// Upload media
const { url } = await client.uploadMedia(fileBuffer, "image/jpeg");

// Cold-start a conversation from a recipient (WhatsApp needs a template)
const convo = await client.createConversation({
accountId,
participantId: "16505551234",
templateName: "welcome",
templateLanguage: "en_US",
});

// WhatsApp rich sends: sendInteractive, sendLocation, sendContacts,
// sendTemplate, reply — see "WhatsApp Rich Messages" above.
```

## Webhook Verification
Expand Down
87 changes: 87 additions & 0 deletions src/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,53 @@ describe("Thread ID encode/decode", () => {
});
});

// ─── openDM / openConversation ──────────────────────────────────────────────

describe("openDM", () => {
const adapter = new ZernioAdapter(TEST_CONFIG);

it("resolves an account-namespaced recipient to a thread id (no network)", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const threadId = await adapter.openDM("acc-123:16505551234");
expect(threadId).toBe("zernio:acc-123:16505551234");
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});

it("throws when the recipient is not account-namespaced", async () => {
await expect(adapter.openDM("16505551234")).rejects.toThrow(ValidationError);
await expect(adapter.openDM("acc-123:")).rejects.toThrow(ValidationError);
});
});

describe("openConversation", () => {
const adapter = new ZernioAdapter(TEST_CONFIG);
afterEach(() => vi.restoreAllMocks());

it("cold-starts a WhatsApp conversation with a template and returns the thread id", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(
JSON.stringify({ success: true, data: { conversationId: "16505551234", messageId: "wamid.1", participantId: "16505551234", participantName: "16505551234" } }),
{ status: 201 },
),
);
const threadId = await adapter.openConversation({
accountId: "acc-9",
to: "16505551234",
template: { name: "welcome", language: "en_US", params: ["Ana"] },
});
expect(threadId).toBe("zernio:acc-9:16505551234");
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(body).toMatchObject({
accountId: "acc-9",
participantId: "16505551234",
templateName: "welcome",
templateLanguage: "en_US",
templateParams: ["Ana"],
});
});
});

// ─── parseMessage Tests ─────────────────────────────────────────────────────

describe("parseMessage", () => {
Expand Down Expand Up @@ -289,6 +336,46 @@ describe("handleWebhook", () => {
expect(typeof factoryArg).toBe("function");
});

it("surfaces WhatsApp interactive-reply metadata on message.raw.metadata", async () => {
const payload = makeWebhookPayload({
metadata: { interactiveType: "list_reply", interactiveId: "row-pro" },
} as any);
const body = JSON.stringify(payload);
const request = new Request("https://example.com/webhook", {
method: "POST",
headers: {
"X-Zernio-Signature": signPayload(body),
"X-Zernio-Event": "message.received",
"Content-Type": "application/json",
},
body,
});

await adapter.handleWebhook(request);
const [, , factoryArg] = mockChat.processMessage.mock.calls[0];
const message = await factoryArg();
expect(message.raw.metadata).toEqual({ interactiveType: "list_reply", interactiveId: "row-pro" });
});

it("skips call.* and message-status events (not Chat SDK concepts)", async () => {
for (const event of ["call.received", "call.ended", "message.delivered", "message.read"]) {
const body = JSON.stringify({ id: "e", event, timestamp: "t" });
const request = new Request("https://example.com/webhook", {
method: "POST",
headers: {
"X-Zernio-Signature": signPayload(body),
"X-Zernio-Event": event,
"Content-Type": "application/json",
},
body,
});
const response = await adapter.handleWebhook(request);
expect(response.status).toBe(200);
}
expect(mockChat.processMessage).not.toHaveBeenCalled();
expect(mockChat.processReaction).not.toHaveBeenCalled();
});

it("skips outgoing messages (prevents echo loop)", async () => {
const payload = makeWebhookPayload({
message: makeRawMessage({ direction: "outgoing" }),
Expand Down
82 changes: 78 additions & 4 deletions src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ export class ZernioAdapter implements Adapter<ZernioThreadId, ZernioRawMessage>
return this.handleReactionReceived(payload as ZernioReactionWebhookPayload, options);
}

// Unhandled event type, acknowledge receipt
// Unhandled event type, acknowledge receipt. (Call lifecycle + message
// delivery-status events are intentionally NOT handled here: they aren't Chat
// SDK concepts. Subscribe to them with your own Zernio webhook handler.)
return new Response("OK", { status: 200 });
}

Expand All @@ -184,8 +186,14 @@ export class ZernioAdapter implements Adapter<ZernioThreadId, ZernioRawMessage>
conversationId: payload.message.conversationId,
});

// Copy the envelope metadata (interactive reply, ad referral, quoted-message
// context) onto the raw message so handlers can read it off message.raw.metadata.
const rawMessage: ZernioRawMessage = payload.metadata
? { ...payload.message, metadata: payload.metadata }
: payload.message;

const factory = async (): Promise<Message<ZernioRawMessage>> => {
return this.parseMessage(payload.message);
return this.parseMessage(rawMessage);
};

this.chat!.processMessage(this, threadId, factory, options);
Expand Down Expand Up @@ -342,8 +350,14 @@ export class ZernioAdapter implements Adapter<ZernioThreadId, ZernioRawMessage>
dateSent: new Date(raw.sentAt),
edited: false,
},
// chat-sdk Attachment.type is fixed to image/file/video/audio. Media types
// pass through; everything else (location, contact, sticker, share) maps to
// "file" — the true Zernio type + payload stay on `raw.attachments`.
attachments: raw.attachments.map((att) => ({
type: att.type as "image" | "video" | "audio" | "file",
type:
att.type === "image" || att.type === "video" || att.type === "audio"
? att.type
: ("file" as const),
url: att.url,
})),
});
Expand Down Expand Up @@ -374,11 +388,13 @@ export class ZernioAdapter implements Adapter<ZernioThreadId, ZernioRawMessage>
const body: Record<string, unknown> = { accountId };

if (card) {
// Map card to native Zernio rich message format (buttons, templates)
// Map card to native Zernio rich message format (buttons, templates, or a
// WhatsApp interactive list when the card carries a Select/RadioSelect).
const mapped = mapCardToZernioMessage(card as any);
body.message = mapped.message || undefined;
if (mapped.buttons) body.buttons = mapped.buttons;
if (mapped.template) body.template = mapped.template;
if (mapped.interactive) body.interactive = mapped.interactive;
} else {
// Plain text / markdown / AST
body.message = this.converter.renderPostable(message) || undefined;
Expand Down Expand Up @@ -497,6 +513,64 @@ export class ZernioAdapter implements Adapter<ZernioThreadId, ZernioRawMessage>
await this.api.deleteMessage(conversationId, messageId, accountId);
}

// ─── Opening conversations ────────────────────────────────────────────────

/**
* Open a direct-message thread with a recipient (Chat SDK `chat.openDM`).
*
* Because one Zernio account = one channel, the recipient must be namespaced
* with the account: pass `userId` as `"{accountId}:{recipient}"` (the
* recipient is a phone/E.164 for WhatsApp, or the platform user id otherwise).
*
* This is resolution-only and makes NO network call: the Zernio inbox send
* endpoint accepts the recipient handle directly as the conversation id, so we
* return the thread id deterministically and the FIRST `post()` opens the
* conversation. For WhatsApp that first message must be an approved template
* (24h-window rule) — use `openConversation()` to send it in one step.
*/
async openDM(userId: string): Promise<string> {
const sep = userId.indexOf(":");
if (sep <= 0 || sep === userId.length - 1) {
throw new ValidationError(
"zernio",
`openDM expects "{accountId}:{recipient}" (one account = one channel). Got "${userId}".`,
);
}
const accountId = userId.slice(0, sep);
const recipient = userId.slice(sep + 1);
return this.encodeThreadId({ accountId, conversationId: recipient });
}

/**
* Cold-start a conversation by sending its opening message, and return the
* thread id. Unlike `openDM`, this actually creates the conversation via the
* Zernio API, so it works for WhatsApp: pass a `template` (the only way to
* open outside the 24h window). Other platforms can open with `message`.
*/
async openConversation(params: {
accountId: string;
/** Recipient: phone/E.164 for WhatsApp, platform user id otherwise. */
to: string;
/** Opening text (non-WhatsApp, or WhatsApp within the 24h window). */
message?: string;
/** WhatsApp approved template — required to open a WhatsApp conversation cold. */
template?: { name: string; language: string; params?: string[] };
}): Promise<string> {
const data = await this.api.createConversation({
accountId: params.accountId,
participantId: params.to,
...(params.message ? { message: params.message } : {}),
...(params.template
? {
templateName: params.template.name,
templateLanguage: params.template.language,
...(params.template.params ? { templateParams: params.template.params } : {}),
}
: {}),
});
return this.encodeThreadId({ accountId: params.accountId, conversationId: data.conversationId });
}

// ─── Reactions ────────────────────────────────────────────────────────────

/**
Expand Down
Loading
Loading