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
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,12 @@ const { accountId, conversationId } = adapter.decodeThreadId(threadId);
| Rich messages (cards) | Yes | Buttons and templates on FB, IG, Telegram, WhatsApp |
| Edit messages | Partial | Telegram only |
| Delete messages | Partial | Telegram, X (full delete); Bluesky, Reddit (self-only) |
| Reactions | Partial | Telegram and WhatsApp (add/remove emoji) |
| Send reactions | Partial | Telegram and WhatsApp (add/remove emoji) |
| Receive reactions (`onReaction`) | Partial | WhatsApp, Telegram (via the `reaction.received` webhook) |
| Typing indicators | Partial | Facebook Messenger, Telegram, and WhatsApp (requires recent inbound message) |
| AI streaming | Partial | Post+edit on Telegram; single post on others |
| File attachments | Yes | Via media upload endpoint |
| Fetch messages | Yes | Full conversation history |
| Fetch messages | Yes | Full conversation history (supports `limit`, `cursor`, `direction`) |
| Fetch thread info | Yes | Participant details, platform, status |
| Webhook verification | Yes | HMAC-SHA256 signature |
| Comment webhooks | Yes | `comment.received` routed through handlers |
Expand Down Expand Up @@ -239,8 +240,11 @@ const { data, pagination } = await client.listConversations({
limit: 20,
});

// Fetch messages
const messages = await client.fetchMessages(conversationId, accountId);
// Fetch messages (optionally paginated: limit, opaque cursor, sortOrder)
const messages = await client.fetchMessages(conversationId, accountId, {
limit: 20,
sortOrder: "desc", // newest first; omit for oldest-first (default)
});

// Send typing indicator
await client.sendTyping(conversationId, accountId);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zernio/chat-sdk-adapter",
"version": "0.2.3",
"version": "0.3.0",
"description": "Official Zernio adapter for Chat SDK — build chatbots across Instagram, Facebook, Twitter/X, Telegram, WhatsApp, Bluesky, and Reddit through a single integration",
"type": "module",
"engines": {
Expand Down
241 changes: 238 additions & 3 deletions src/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createHmac } from "node:crypto";
import { Message } from "chat";
import { ValidationError, AdapterError } from "@chat-adapter/shared";
Expand Down Expand Up @@ -36,6 +36,36 @@ function makeRawMessage(overrides?: Partial<ZernioRawMessage>): ZernioRawMessage
};
}

/**
* Creates a message in the shape the REST GET /messages endpoint actually
* returns (flat senderId/senderName/message/createdAt), which is DIFFERENT from
* the nested webhook payload shape that `makeRawMessage` produces. See
* libs/inbox/message-fetching.ts in the Zernio API repo. Regression fixture for
* GitHub issue #3 (fetchMessages threw on this shape).
*/
function makeRestMessage(overrides?: Record<string, unknown>): Record<string, unknown> {
return {
id: "wamid.ABC",
conversationId: "conv-456",
accountId: "acc-1",
platform: "whatsapp",
message: "hi there",
senderId: "13866666863",
senderName: "13866666863",
senderPhoneNumber: "+13866666863",
direction: "incoming",
createdAt: "2026-03-29T10:00:00.000Z",
attachments: [],
sentAt: "2026-03-29T10:00:00.000Z",
...overrides,
};
}

/** Builds an async iterable of string chunks for stream() tests. */
async function* asyncChunks(...chunks: string[]): AsyncIterable<string> {
for (const c of chunks) yield c;
}

/** Creates a minimal valid webhook payload. */
function makeWebhookPayload(overrides?: Partial<ZernioWebhookPayload>): ZernioWebhookPayload {
return {
Expand Down Expand Up @@ -192,6 +222,7 @@ describe("handleWebhook", () => {
debug: vi.fn(),
}),
processMessage: vi.fn(),
processReaction: vi.fn(),
};
adapter.initialize(mockChat);
});
Expand Down Expand Up @@ -343,6 +374,84 @@ describe("handleWebhook", () => {
expect(threadIdArg).toBe("zernio:acc-789:comment:post-123");
});

it("routes reaction.received to processReaction (not processMessage)", async () => {
const payload = {
id: "evt-003",
event: "reaction.received",
timestamp: "2026-03-29T10:00:00.000Z",
reaction: {
emoji: "👍",
action: "added",
messageId: "msg-zernio-1",
platformMessageId: "wamid.REACTED",
sender: { id: "13866666863", name: "Yair", phoneNumber: "+13866666863" },
reactedAt: "2026-03-29T10:00:00.000Z",
},
conversation: { id: "conv-456", platformConversationId: "wa-conv", status: "active" },
account: { id: "acc-789", platform: "whatsapp", username: "mybrand" },
};
const body = JSON.stringify(payload);
const signature = signPayload(body);

const request = new Request("https://example.com/webhook", {
method: "POST",
headers: {
"X-Zernio-Signature": signature,
"X-Zernio-Event": "reaction.received",
"Content-Type": "application/json",
},
body,
});

const response = await adapter.handleWebhook(request);
expect(response.status).toBe(200);
expect(mockChat.processReaction).toHaveBeenCalledOnce();
expect(mockChat.processMessage).not.toHaveBeenCalled();

const [event] = mockChat.processReaction.mock.calls[0];
expect(event.threadId).toBe("zernio:acc-789:conv-456");
expect(event.added).toBe(true);
expect(event.rawEmoji).toBe("👍");
expect(event.messageId).toBe("msg-zernio-1");
expect(event.user.userId).toBe("13866666863");
// Normalized to a known name via the unicode resolver.
expect(event.emoji.name).toBe("thumbs_up");
});

it("falls back to platformMessageId when reaction has no resolved messageId", async () => {
const payload = {
id: "evt-004",
event: "reaction.received",
timestamp: "2026-03-29T10:00:00.000Z",
reaction: {
emoji: "",
action: "removed",
platformMessageId: "wamid.REACTED",
sender: { id: "13866666863" },
reactedAt: "2026-03-29T10:00:00.000Z",
},
conversation: { id: "conv-456", platformConversationId: "wa-conv", status: "active" },
account: { id: "acc-789", platform: "whatsapp", username: "mybrand" },
};
const body = JSON.stringify(payload);
const signature = signPayload(body);

const request = new Request("https://example.com/webhook", {
method: "POST",
headers: {
"X-Zernio-Signature": signature,
"X-Zernio-Event": "reaction.received",
"Content-Type": "application/json",
},
body,
});

await adapter.handleWebhook(request);
const [event] = mockChat.processReaction.mock.calls[0];
expect(event.added).toBe(false);
expect(event.messageId).toBe("wamid.REACTED");
});

it("skips signature verification when no webhookSecret is configured", async () => {
const adapterNoSecret = new ZernioAdapter({
...TEST_CONFIG,
Expand Down Expand Up @@ -449,7 +558,26 @@ describe("API-backed methods", () => {
expect(body.message).toBeUndefined();
});

it("fetchMessages returns parsed messages", async () => {
it("fetchMessages parses the real REST flat shape (issue #3)", async () => {
// Regression: parseMessage used to read raw.sender.id, but the REST endpoint
// returns a flat senderId. This must not throw.
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({
status: "success",
messages: [makeRestMessage({ message: "hi there" })],
pagination: { hasMore: false, nextCursor: null },
sortOrderApplied: "desc",
lastUpdated: "2026-03-29T10:00:00Z",
}), { status: 200 }),
);
const result = await adapter.fetchMessages("zernio:acc-1:conv-2", { limit: 10 });
expect(result.messages).toHaveLength(1);
expect(result.messages[0].text).toBe("hi there");
expect(result.messages[0].author.userId).toBe("13866666863");
expect(result.messages[0].author.fullName).toBe("13866666863");
});

it("fetchMessages still tolerates the nested webhook shape", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({
status: "success",
Expand All @@ -460,7 +588,51 @@ describe("API-backed methods", () => {
const result = await adapter.fetchMessages("zernio:acc-1:conv-2");
expect(result.messages).toHaveLength(1);
expect(result.messages[0].text).toBe("Hello from Instagram");
expect(result.nextCursor).toBeUndefined();
expect(result.messages[0].author.userId).toBe("user-001");
});

it("fetchMessages forwards limit/cursor and maps default direction to desc", async () => {
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({
status: "success",
messages: [],
pagination: { hasMore: false, nextCursor: null },
lastUpdated: "2026-03-29T10:00:00Z",
}), { status: 200 }),
);
await adapter.fetchMessages("zernio:acc-1:conv-2", { limit: 10, cursor: "db:abc_1" });
const url = String(spy.mock.calls[0][0]);
expect(url).toContain("limit=10");
expect(url).toContain("sortOrder=desc"); // default direction "backward" = most recent
expect(url).toContain("cursor=db%3Aabc_1");
});

it("fetchMessages direction:forward maps to sortOrder=asc", async () => {
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ status: "success", messages: [], lastUpdated: "x" }), { status: 200 }),
);
await adapter.fetchMessages("zernio:acc-1:conv-2", { direction: "forward" });
expect(String(spy.mock.calls[0][0])).toContain("sortOrder=asc");
});

it("fetchMessages returns most-recent page oldest-first (reverses desc) and wires nextCursor", async () => {
// REST desc gives newest-first; FetchResult requires oldest-first within the page.
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({
status: "success",
messages: [
makeRestMessage({ id: "m3", message: "third", createdAt: "2026-03-29T10:03:00.000Z" }),
makeRestMessage({ id: "m2", message: "second", createdAt: "2026-03-29T10:02:00.000Z" }),
makeRestMessage({ id: "m1", message: "first", createdAt: "2026-03-29T10:01:00.000Z" }),
],
pagination: { hasMore: true, nextCursor: "db:older_1" },
sortOrderApplied: "desc",
lastUpdated: "x",
}), { status: 200 }),
);
const result = await adapter.fetchMessages("zernio:acc-1:conv-2", { limit: 3 });
expect(result.messages.map((m) => m.text)).toEqual(["first", "second", "third"]);
expect(result.nextCursor).toBe("db:older_1");
});

it("fetchThread returns thread info with metadata", async () => {
Expand All @@ -484,6 +656,69 @@ describe("API-backed methods", () => {
});
});

// ─── Streaming Tests ────────────────────────────────────────────────────────

describe("stream", () => {
let adapter: ZernioAdapter;

beforeEach(() => {
adapter = new ZernioAdapter(TEST_CONFIG);
});

// Restore the fetch spy between tests so a persistent mockResolvedValue
// from one stream test cannot leak into the next.
afterEach(() => {
vi.restoreAllMocks();
});

/** Mock for the GET fetchThread call that stream() uses to learn the platform. */
function mockFetchThread(platform: string): Response {
return new Response(JSON.stringify({
data: { id: "conv-2", accountId: "acc-1", platform, status: "active" },
}), { status: 200 });
}

it("non-editable platform: buffers full stream and posts ONCE with complete text", async () => {
const spy = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(mockFetchThread("whatsapp")) // fetchThread
.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: { messageId: "wamid.X" } }), { status: 200 })); // sendMessage

const result = await adapter.stream("zernio:acc-1:conv-2", asyncChunks("Hello", " ", "world"));

// Exactly one POST send (after the GET fetchThread); no PATCH edits.
const calls = spy.mock.calls;
const sendCalls = calls.filter((c) => c[1]?.method === "POST");
const editCalls = calls.filter((c) => c[1]?.method === "PATCH");
expect(sendCalls).toHaveLength(1);
expect(editCalls).toHaveLength(0);
expect(JSON.parse(sendCalls[0][1].body as string).message).toBe("Hello world");
expect(result.raw.text).toBe("Hello world");
});

it("telegram: posts then edits (live streaming)", async () => {
const spy = vi.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(JSON.stringify({ success: true, data: { messageId: "tg-1" } }), { status: 200 }));
// First call is fetchThread (GET) returning telegram
spy.mockResolvedValueOnce(mockFetchThread("telegram"));

const result = await adapter.stream("zernio:acc-1:conv-2", asyncChunks("Hel", "lo"));
const editCalls = spy.mock.calls.filter((c) => c[1]?.method === "PATCH");
expect(editCalls.length).toBeGreaterThanOrEqual(1); // at least the final edit
expect(result.raw.text).toBe("Hello");
});

it("falls back to post-once if platform detection fails", async () => {
const spy = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("boom", { status: 500 })) // fetchThread fails
.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, data: { messageId: "x" } }), { status: 200 }));

const result = await adapter.stream("zernio:acc-1:conv-2", asyncChunks("a", "b", "c"));
const editCalls = spy.mock.calls.filter((c) => c[1]?.method === "PATCH");
expect(editCalls).toHaveLength(0);
expect(result.raw.text).toBe("abc");
});
});

// ─── Optional Method Tests ──────────────────────────────────────────────────

describe("optional methods", () => {
Expand Down
Loading
Loading