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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const { accountId, conversationId } = adapter.decodeThreadId(threadId);
| 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) |
| Send reactions | Partial | Telegram, WhatsApp, Instagram, Facebook Messenger (add/remove emoji) |
| Receive reactions (`onReaction`) | Partial | WhatsApp, Telegram (via the `reaction.received` webhook) |
| Typing indicators | Partial | Facebook Messenger, Instagram, and Telegram; WhatsApp (requires a recent inbound message in the conversation) |
| AI streaming | Partial | Post+edit on Telegram; single post on others |
Expand All @@ -157,7 +157,7 @@ const { accountId, conversationId } = adapter.decodeThreadId(threadId);
| Templates / Flows | - | - | - | Y | - | - | - |
| Typing | Y | Y | Y | Y | - | - | - |
| Delete | - | - | Y | - | Y | Self | Self |
| Reactions | - | - | Y | Y | - | - | - |
| Reactions | Y | Y | Y | Y | - | - | - |
| Media | Y | Y | Y | Y | Y | - | - |
| Edit | - | - | Y | - | - | - | - |

Expand Down
32 changes: 29 additions & 3 deletions src/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
import { createHmac } from "node:crypto";
import { Message } from "chat";
import { Message, defaultEmojiResolver } from "chat";
import { ValidationError, AdapterError } from "@chat-adapter/shared";
import { ZernioAdapter } from "./adapter.js";
import type { ZernioRawMessage, ZernioWebhookPayload } from "./types.js";
Expand Down Expand Up @@ -200,11 +200,22 @@ describe("parseMessage", () => {
const msg = adapter.parseMessage(raw);

expect(msg).toBeInstanceOf(Message);
expect(msg.id).toBe("msg-123");
// The platform-native id, NOT the Zernio internal id: every outbound
// message op (reactions, edit, delete) passes this id to the Zernio API,
// which forwards it verbatim to the platform. Using the internal id made
// every reaction on a webhook-received message fail (issue #9). It also
// matches the ids returned by REST fetches and postMessage.
expect(msg.id).toBe("ig-msg-789");
expect(msg.text).toBe("Hello from Instagram");
expect(msg.raw).toBe(raw);
});

it("falls back to the Zernio id when platformMessageId is missing", () => {
const raw = makeRawMessage({ platformMessageId: "" });
const msg = adapter.parseMessage(raw);
expect(msg.id).toBe("msg-123");
});

it("maps author fields correctly", () => {
const raw = makeRawMessage();
const msg = adapter.parseMessage(raw);
Expand Down Expand Up @@ -499,7 +510,9 @@ describe("handleWebhook", () => {
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");
// Platform-native id, consistent with Message.id everywhere else in the
// adapter, so handlers can react/edit/delete using event.messageId.
expect(event.messageId).toBe("wamid.REACTED");
expect(event.user.userId).toBe("13866666863");
// Normalized to a known name via the unicode resolver.
expect(event.emoji.name).toBe("thumbs_up");
Expand Down Expand Up @@ -602,6 +615,19 @@ describe("API-backed methods", () => {
expect(body.accountId).toBe("acc-1");
});

it("addReaction converts an EmojiValue to the unicode emoji, not its name (issue #9)", async () => {
// Regression: chat-sdk hands the adapter EmojiValue objects; sending
// emoji.name ("thumbs_up") made every platform reject the reaction.
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ success: true }), { status: 200 }),
);
const emojiValue = defaultEmojiResolver.fromGChat("👍");
expect(emojiValue.name).toBe("thumbs_up");
await adapter.addReaction("zernio:acc-1:conv-2", "msg-3", emojiValue);
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(body.emoji).toBe("👍");
});

it("removeReaction calls the API DELETE endpoint", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(JSON.stringify({ success: true }), { status: 200 }),
Expand Down
24 changes: 17 additions & 7 deletions src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,9 +269,10 @@ export class ZernioAdapter implements Adapter<ZernioThreadId, ZernioRawMessage>
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,
// The message that was reacted to: the platform-native id, consistent
// with Message.id everywhere else in the adapter, so handlers can pass
// it straight back to addReaction/editMessage/deleteMessage (issue #9).
messageId: reaction.platformMessageId || reaction.messageId || "",
threadId,
user: {
userId: reaction.sender.id,
Expand Down Expand Up @@ -334,7 +335,12 @@ export class ZernioAdapter implements Adapter<ZernioThreadId, ZernioRawMessage>
const text = raw.text ?? "";

return new Message<ZernioRawMessage>({
id: raw.id,
// The platform-native id, not the Zernio internal id: message ops
// (reactions, edit, delete) send this id to the Zernio API, which
// forwards it verbatim to the platform. The internal id made every such
// op on a webhook-received message fail (issue #9), and REST fetches +
// postMessage already return platform ids. The Zernio id stays on raw.id.
id: raw.platformMessageId || raw.id,
threadId: "", // Set by chat-sdk's processMessage
text,
formatted: this.converter.toAst(text),
Expand Down Expand Up @@ -575,7 +581,7 @@ export class ZernioAdapter implements Adapter<ZernioThreadId, ZernioRawMessage>

/**
* Add a reaction to a message.
* Supported on: Telegram (emoji reactions), WhatsApp (emoji reactions).
* Supported on: Telegram, WhatsApp, Slack, Instagram, Facebook Messenger.
* Unsupported on other platforms (API returns 400).
*/
async addReaction(
Expand All @@ -584,13 +590,17 @@ export class ZernioAdapter implements Adapter<ZernioThreadId, ZernioRawMessage>
emoji: EmojiValue | string,
): Promise<void> {
const { accountId, conversationId } = this.decodeThreadId(threadId);
const emojiStr = typeof emoji === "string" ? emoji : emoji.name;
// Zernio's wire format is the unicode character. toGChat maps an EmojiValue
// or normalized name ("thumbs_up") to unicode and passes through anything it
// doesn't know (an already-unicode "👍"). Sending emoji.name made every
// platform reject the reaction (issue #9).
const emojiStr = defaultEmojiResolver.toGChat(emoji);
await this.api.addReaction(conversationId, messageId, accountId, emojiStr);
}

/**
* Remove a reaction from a message.
* Supported on: Telegram (send empty reaction), WhatsApp (send empty emoji).
* Supported on: Telegram, WhatsApp, Slack, Instagram, Facebook Messenger.
* Unsupported on other platforms (API returns 400).
*
* Note: The `emoji` parameter is required by the chat-sdk Adapter interface but
Expand Down
Loading