diff --git a/README.md b/README.md index 4ba398a..2c0b837 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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); diff --git a/package.json b/package.json index 6cd4ee8..7dbb458 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/adapter.test.ts b/src/adapter.test.ts index 597d11c..3482045 100644 --- a/src/adapter.test.ts +++ b/src/adapter.test.ts @@ -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"; @@ -36,6 +36,36 @@ function makeRawMessage(overrides?: Partial): 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): Record { + 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 { + for (const c of chunks) yield c; +} + /** Creates a minimal valid webhook payload. */ function makeWebhookPayload(overrides?: Partial): ZernioWebhookPayload { return { @@ -192,6 +222,7 @@ describe("handleWebhook", () => { debug: vi.fn(), }), processMessage: vi.fn(), + processReaction: vi.fn(), }; adapter.initialize(mockChat); }); @@ -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, @@ -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", @@ -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 () => { @@ -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", () => { diff --git a/src/adapter.ts b/src/adapter.ts index 5df5286..56eb9ed 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -13,6 +13,7 @@ import { ConsoleLogger, Message, + defaultEmojiResolver, type Adapter, type AdapterPostableMessage, type ChatInstance, @@ -37,11 +38,14 @@ import { ZernioFormatConverter } from "./format-converter.js"; import { mapCardToZernioMessage } from "./card-mapper.js"; import { verifyWebhookSignature, extractWebhookHeaders } from "./webhook.js"; import type { + ZernioAttachment, ZernioConfig, ZernioRawMessage, + ZernioRestMessage, ZernioThreadId, ZernioWebhookPayload, ZernioCommentWebhookPayload, + ZernioReactionWebhookPayload, } from "./types.js"; /** Prefix used in all Zernio thread IDs. */ @@ -154,6 +158,10 @@ export class ZernioAdapter implements Adapter return this.handleCommentReceived(payload as ZernioCommentWebhookPayload, options); } + if (payload.event === "reaction.received") { + return this.handleReactionReceived(payload as ZernioReactionWebhookPayload, options); + } + // Unhandled event type, acknowledge receipt return new Response("OK", { status: 200 }); } @@ -224,6 +232,53 @@ export class ZernioAdapter implements Adapter return new Response("OK", { status: 200 }); } + /** + * Handle a reaction.received webhook event. + * + * Routes to chat-sdk's reaction dispatch (`onReaction`) instead of the message + * pipeline. Previously Zernio delivered reactions as message.received with the + * emoji as the text, so a 👍 looked like an inbound DM (GitHub issue: onReaction + * never fired). The raw platform emoji is normalized to an EmojiValue via the + * default resolver's unicode path (WhatsApp/Telegram reactions are unicode, the + * same shape as Google Chat / Discord). + */ + private handleReactionReceived( + payload: ZernioReactionWebhookPayload, + options?: WebhookOptions, + ): Response { + const { reaction } = payload; + const threadId = this.encodeThreadId({ + accountId: payload.account.id, + conversationId: payload.conversation.id, + }); + + // chat-sdk requires an EmojiValue. fromGChat normalizes a raw unicode emoji + // (e.g. "👍" -> thumbs_up) and falls back to a raw EmojiValue when unknown. + const emojiValue = defaultEmojiResolver.fromGChat(reaction.emoji); + + this.chat!.processReaction( + { + added: reaction.action === "added", + emoji: emojiValue, + rawEmoji: reaction.emoji, + // The message that was reacted to. Prefer the Zernio id; fall back to the + // platform id (always present). + messageId: reaction.messageId ?? reaction.platformMessageId, + threadId, + user: { + userId: reaction.sender.id, + userName: reaction.sender.username ?? reaction.sender.id, + fullName: reaction.sender.name ?? "", + isBot: false, + isMe: false, + }, + raw: payload, + }, + options, + ); + return new Response("OK", { status: 200 }); + } + // ─── Thread ID Encoding ─────────────────────────────────────────────────── /** @@ -479,23 +534,80 @@ export class ZernioAdapter implements Adapter // ─── Fetching ───────────────────────────────────────────────────────────── + /** + * Normalize a REST `GET /messages` message into the webhook-shaped + * `ZernioRawMessage` that `parseMessage` expects. + * + * The REST list endpoint flattens the sender (`senderId`/`senderName`), names + * the body `message`, and uses `createdAt`; the webhook payload nests `sender` + * and uses `text`/`sentAt`. `parseMessage` was written against the webhook + * shape, so feeding it REST messages directly threw on `raw.sender.id` + * (GitHub issue #3). This bridges the two, tolerating EITHER shape so the + * adapter keeps working if the REST surface later converges on the webhook one. + */ + private restToRawMessage(m: ZernioRestMessage): ZernioRawMessage { + // A REST message may already carry webhook-shaped fields; read them defensively. + const webhookShape = m as Partial; + return { + id: m.id, + conversationId: m.conversationId ?? "", + platform: m.platform ?? "", + platformMessageId: webhookShape.platformMessageId ?? m.id, + direction: m.direction, + text: webhookShape.text ?? m.message ?? null, + attachments: Array.isArray(m.attachments) + ? m.attachments.map((a) => ({ + type: a.type as ZernioAttachment["type"], + url: a.url ?? "", + ...(a.payload ? { payload: a.payload } : {}), + })) + : [], + sender: webhookShape.sender ?? { + id: m.senderId ?? "", + name: m.senderName, + ...(m.senderPhoneNumber ? { phoneNumber: m.senderPhoneNumber } : {}), + }, + sentAt: webhookShape.sentAt ?? m.sentAt ?? m.createdAt ?? new Date().toISOString(), + isRead: webhookShape.isRead ?? m.isRead ?? false, + }; + } + /** * Fetch messages for a conversation. - * Returns all messages (no cursor pagination on the Zernio messages endpoint currently). - * Messages are returned in chronological order (oldest first). + * + * chat-sdk semantics (FetchResult): messages come back oldest-first WITHIN the + * page, and `direction` selects which page: + * - `backward` (default): the N most recent messages → Zernio `sortOrder=desc`, + * then we reverse the page to chronological order. `nextCursor` walks older. + * - `forward`: the N oldest (or next N after the cursor) → Zernio `sortOrder=asc`. + * + * `limit` and `cursor` are forwarded to the REST endpoint (previously dropped, + * so callers always got the oldest 100). `nextCursor` is wired from the + * endpoint's `pagination.nextCursor` (previously hardcoded `undefined`). */ async fetchMessages( threadId: string, - _options?: FetchOptions, + options?: FetchOptions, ): Promise> { const { accountId, conversationId } = this.decodeThreadId(threadId); - const response = await this.api.fetchMessages(conversationId, accountId); - const messages = response.messages.map((raw) => this.parseMessage(raw)); + const direction = options?.direction ?? "backward"; + const sortOrder = direction === "forward" ? "asc" : "desc"; + + const response = await this.api.fetchMessages(conversationId, accountId, { + limit: options?.limit, + cursor: options?.cursor, + sortOrder, + }); + + const rawList = response.messages ?? []; + // `desc` returns newest-first; FetchResult wants oldest-first within the page. + const ordered = sortOrder === "desc" ? [...rawList].reverse() : rawList; + const messages = ordered.map((raw) => this.parseMessage(this.restToRawMessage(raw))); return { messages, - nextCursor: undefined, + nextCursor: response.pagination?.nextCursor ?? undefined, }; } @@ -542,16 +654,21 @@ export class ZernioAdapter implements Adapter // ─── Streaming ────────────────────────────────────────────────────────── /** - * Stream AI responses using post-then-edit pattern. - * Posts an initial message, then edits it as tokens arrive. + * Stream an AI response to a conversation. * - * Works on Telegram (supports message editing). On other platforms, - * collects the full stream and posts once since editing isn't supported. + * Only platforms that support message editing (Telegram today) can render a + * response token-by-token. On every other platform (WhatsApp, Instagram, + * Facebook, X, Bluesky, Reddit) `editMessage` returns an error, so the old + * post-then-edit approach delivered ONLY the first chunk and silently dropped + * the rest. We therefore detect the platform up front: + * - Telegram: post an initial message and edit it as chunks arrive (throttled). + * - Everyone else (or if detection fails): buffer the whole stream and post + * once, so the recipient always receives the complete reply. */ async stream( threadId: string, textStream: AsyncIterable, - options?: import("chat").StreamOptions, + _options?: import("chat").StreamOptions, ): Promise> { const { accountId, conversationId } = this.decodeThreadId(threadId); @@ -562,13 +679,35 @@ export class ZernioAdapter implements Adapter return ""; }; - // Collect the first chunk to have initial content + // Determine whether the platform supports live message editing. Default to + // "no" (post-once) when the lookup fails, since a complete message is always + // better than a truncated one. + let supportsEditing = false; + try { + const info = await this.fetchThread(threadId); + supportsEditing = info.metadata?.platform === "telegram"; + } catch { + supportsEditing = false; + } + + // Non-editable platforms: collect the full response, then post it once. + if (!supportsEditing) { + let full = ""; + for await (const chunk of textStream) full += chunkToText(chunk); + const result = await this.api.sendMessage(conversationId, { + accountId, + message: full || "...", + }); + const messageId = (result.messageId as string) ?? (result.id as string) ?? ""; + return this.buildOutgoingRaw(messageId, threadId, conversationId, full); + } + + // Editable platform (Telegram): post-then-edit as chunks arrive (throttled). let buffer = ""; const iterator = textStream[Symbol.asyncIterator](); const first = await iterator.next(); if (!first.done) buffer = chunkToText(first.value); - // Post initial message const result = await this.api.sendMessage(conversationId, { accountId, message: buffer || "...", @@ -590,21 +729,35 @@ export class ZernioAdapter implements Adapter }); lastEditTime = now; } catch { - // Edit failed (platform doesn't support it), continue collecting + // Edit failed mid-stream; keep collecting and try the final edit. } } } - // Final edit with complete text + // Final edit with the complete text. try { await this.api.editMessage(conversationId, messageId, { accountId, text: buffer, }); } catch { - // If edit fails, the last successful edit or initial post is the final state + // The last successful edit / initial post remains the final state. } + return this.buildOutgoingRaw(messageId, threadId, conversationId, buffer); + } + + /** + * Build the synthetic outgoing RawMessage returned by the streaming paths. + * The Zernio send API doesn't echo the full message object back, so we + * reconstruct the minimal shape chat-sdk needs. + */ + private buildOutgoingRaw( + messageId: string, + threadId: string, + conversationId: string, + text: string, + ): RawMessage { return { id: messageId, threadId, @@ -614,7 +767,7 @@ export class ZernioAdapter implements Adapter platform: "", platformMessageId: "", direction: "outgoing", - text: buffer, + text, attachments: [], sender: { id: "bot" }, sentAt: new Date().toISOString(), diff --git a/src/api-client.ts b/src/api-client.ts index ec22856..8f21fbb 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -72,14 +72,24 @@ export class ZernioApiClient { /** * Fetch messages for a conversation. * GET /v1/inbox/conversations/{conversationId}/messages?accountId=... + * + * Forwards pagination/ordering when provided: + * - `limit` page size (endpoint caps at 100) + * - `cursor` opaque cursor from a prior `pagination.nextCursor` + * - `sortOrder` 'asc' (oldest first) | 'desc' (newest first) */ async fetchMessages( conversationId: string, accountId: string, + options?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }, ): Promise { + const params = new URLSearchParams({ accountId }); + if (options?.limit != null) params.set("limit", String(options.limit)); + if (options?.cursor) params.set("cursor", options.cursor); + if (options?.sortOrder) params.set("sortOrder", options.sortOrder); return this.request( "GET", - `/v1/inbox/conversations/${conversationId}/messages?accountId=${encodeURIComponent(accountId)}`, + `/v1/inbox/conversations/${conversationId}/messages?${params.toString()}`, ); } diff --git a/src/types.ts b/src/types.ts index 485e8c3..2d0d5a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -134,6 +134,32 @@ export interface ZernioWebhookComment { parentCommentId: string | null; } +/** + * Full reaction.received webhook payload envelope. + * + * Fired when a participant adds or removes an emoji reaction (WhatsApp, Telegram). + * Distinct from message.received so reactions route to chat-sdk's onReaction + * instead of being mistaken for an inbound DM. + */ +export interface ZernioReactionWebhookPayload { + id: string; + event: "reaction.received"; + timestamp: string; + reaction: { + /** The emoji reacted with. May be empty on `removed` (WhatsApp). */ + emoji: string; + action: "added" | "removed"; + /** Zernio Message id of the reacted-to message, when resolvable. */ + messageId?: string; + /** Platform-native id of the reacted-to message (e.g. WhatsApp wamid). */ + platformMessageId: string; + sender: ZernioSender; + reactedAt: string; + }; + conversation: ZernioWebhookConversation; + account: ZernioWebhookAccount; +} + /** Full comment.received webhook payload envelope. */ export interface ZernioCommentWebhookPayload { id: string; @@ -190,10 +216,49 @@ export interface ZernioConversation { updatedTime?: string; } +/** + * A message as returned by the REST `GET /messages` LIST endpoint. + * + * IMPORTANT: this is a DIFFERENT shape from the webhook `ZernioRawMessage`. + * The REST endpoint flattens the sender (`senderId` / `senderName` instead of a + * nested `sender` object), names the body `message` (not `text`), and uses + * `createdAt` (not `sentAt`). The adapter normalizes this into `ZernioRawMessage` + * before parsing (see `ZernioAdapter.restToRawMessage`). Conflating the two shapes + * was the cause of GitHub issue #3 (fetchMessages threw on `raw.sender.id`). + */ +export interface ZernioRestMessage { + id: string; + conversationId?: string; + accountId?: string; + platform?: string; + /** The message body. REST uses `message`; webhook uses `text`. */ + message?: string | null; + senderId?: string; + senderName?: string; + senderPhoneNumber?: string; + direction: "incoming" | "outgoing"; + /** REST uses `createdAt`; webhook uses `sentAt`. Both are ISO strings. */ + createdAt?: string; + sentAt?: string; + attachments?: Array<{ + id?: string; + type: string; + url?: string; + mimeType?: string; + name?: string; + payload?: Record; + }>; + isRead?: boolean; +} + /** Response from GET /v1/inbox/conversations/{conversationId}/messages. */ export interface ZernioMessageListResponse { status: string; - messages: ZernioRawMessage[]; + messages: ZernioRestMessage[]; + /** Present when the endpoint paginates. `nextCursor` is opaque (db:/live: prefixed). */ + pagination?: { hasMore: boolean; nextCursor: string | null }; + /** Order the endpoint actually applied (live-API platforms may ignore the request). */ + sortOrderApplied?: "asc" | "desc"; lastUpdated: string; }