Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tiny-ways-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/whatsapp": minor
---

Add WhatsApp typing indicator support by sending Meta's read-plus-typing payload when a recent inbound message is available. Update the default API version to v25.0.
2 changes: 1 addition & 1 deletion apps/docs/content/docs/adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Ready to build your own? Follow the [building](/docs/contributing/building) guid
| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ |
| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | |
| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ⚠️ |
| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ |

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/adapters/whatsapp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ All options are auto-detected from environment variables when not provided.
| `appSecret` | No* | App secret for webhook signature verification. Auto-detected from `WHATSAPP_APP_SECRET` |
| `phoneNumberId` | No* | Bot's phone number ID. Auto-detected from `WHATSAPP_PHONE_NUMBER_ID` |
| `verifyToken` | No* | Secret for webhook verification handshake. Auto-detected from `WHATSAPP_VERIFY_TOKEN` |
| `apiVersion` | No | Graph API version (defaults to `v21.0`) |
| `apiVersion` | No | Graph API version (defaults to `v25.0`) |
| `userName` | No | Bot username. Auto-detected from `WHATSAPP_BOT_USERNAME` or defaults to `whatsapp-bot` |
| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) |

Expand Down
4 changes: 2 additions & 2 deletions packages/adapter-whatsapp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ All options are auto-detected from environment variables when not provided. You
| `appSecret` | No* | App secret for webhook verification. Auto-detected from `WHATSAPP_APP_SECRET` |
| `phoneNumberId` | No* | Bot's phone number ID. Auto-detected from `WHATSAPP_PHONE_NUMBER_ID` |
| `verifyToken` | No* | Webhook verification secret. Auto-detected from `WHATSAPP_VERIFY_TOKEN` |
| `apiVersion` | No | Graph API version (defaults to `v21.0`) |
| `apiVersion` | No | Graph API version (defaults to `v25.0`) |
| `userName` | No | Bot username for self-message detection. Auto-detected from `WHATSAPP_BOT_USERNAME` (defaults to `whatsapp-bot`) |
| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) |

Expand Down Expand Up @@ -130,7 +130,7 @@ export async function POST(request: Request) {
| Feature | Supported |
|---------|-----------|
| Reactions | Yes (add and remove) |
| Typing indicator | No (not supported by Cloud API) |
| Typing indicator | Yes (requires a recent inbound message and has a 25-second cooldown period) |
| DMs | Yes |
| Open DM | Yes |

Expand Down
95 changes: 91 additions & 4 deletions packages/adapter-whatsapp/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,11 +948,98 @@ describe("addReaction / removeReaction", () => {
// ---------------------------------------------------------------------------

describe("startTyping", () => {
it("is a no-op and does not throw", async () => {
let fetchSpy: MockInstance;

const makeGraphApiResponse = () =>
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});

beforeEach(() => {
fetchSpy = vi
.spyOn(global, "fetch")
.mockImplementation(() => Promise.resolve(makeGraphApiResponse()));
});

afterEach(() => {
fetchSpy.mockRestore();
});

it("resolves latest inbound message ID and sends typing indicator", async () => {
const adapter = createTestAdapter();
await expect(
adapter.startTyping("whatsapp:123456789:15551234567")
).resolves.toBeUndefined();
const threadId = "whatsapp:123456789:15551234567";

// Mock history: 1 inbound message, 1 outbound (bot) message
const mockState = {
getList: vi.fn().mockResolvedValue([
{
_type: "chat:Message",
id: "wamid.inbound123",
threadId,
text: "Hi",
author: {
userId: "15551234567",
userName: "User",
fullName: "User",
isMe: false,
isBot: false,
},
formatted: { type: "root", children: [] },
attachments: [],
metadata: { dateSent: new Date().toISOString(), edited: false },
},
{
_type: "chat:Message",
id: "wamid.outbound456",
threadId,
text: "Hello",
author: {
userId: "123456789",
userName: "bot",
fullName: "bot",
isMe: true,
isBot: true,
},
formatted: { type: "root", children: [] },
attachments: [],
metadata: { dateSent: new Date().toISOString(), edited: false },
},
]),
};

await adapter.initialize({
...mockChat,
getState: () => mockState,
} as any);

await adapter.startTyping(threadId);

expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toContain("/123456789/messages");
const sent = JSON.parse(init?.body as string);
expect(sent.status).toBe("read");
expect(sent.message_id).toBe("wamid.inbound123");
expect(sent.typing_indicator.type).toBe("text");
});

it("does nothing if no inbound message is found in history", async () => {
const adapter = createTestAdapter();
const threadId = "whatsapp:123456789:15551234567";

const mockState = {
getList: vi.fn().mockResolvedValue([]),
};

await adapter.initialize({
...mockChat,
getState: () => mockState,
} as any);

await adapter.startTyping(threadId);

expect(fetchSpy).not.toHaveBeenCalled();
});
});

Expand Down
92 changes: 84 additions & 8 deletions packages/adapter-whatsapp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { extractCard, ValidationError } from "@chat-adapter/shared";
import {
AdapterError,
extractCard,
ValidationError,
} from "@chat-adapter/shared";
import type {
Adapter,
AdapterPostableMessage,
Expand All @@ -24,6 +28,7 @@ import {
defaultEmojiResolver,
getEmoji,
Message,
MessageHistoryCache,
} from "chat";
import { cardToWhatsApp, decodeWhatsAppCallbackData } from "./cards";
import { WhatsAppFormatConverter } from "./markdown";
Expand All @@ -36,11 +41,12 @@ import type {
WhatsAppRawMessage,
WhatsAppSendResponse,
WhatsAppThreadId,
WhatsAppTypingIndicatorResponse,
WhatsAppWebhookPayload,
} from "./types";

/** Default Graph API version */
const DEFAULT_API_VERSION = "v21.0";
const DEFAULT_API_VERSION = "v25.0";

/** Maximum message length for WhatsApp Cloud API */
const WHATSAPP_MESSAGE_LIMIT = 4096;
Expand Down Expand Up @@ -955,13 +961,57 @@ export class WhatsAppAdapter
/**
* Start typing indicator.
*
* WhatsApp supports typing indicators via the messages endpoint.
* The indicator displays for up to 25 seconds or until the next message.
* WhatsApp typing indicators require the most recent inbound message ID.
* They also implicitly mark the referenced message as read.
*
* @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/mark-messages-as-read
* @see https://developers.facebook.com/documentation/business-messaging/whatsapp/typing-indicators
*/
async startTyping(_threadId: string, _status?: string): Promise<void> {
// WhatsApp Cloud API does not support typing indicators.
async startTyping(threadId: string, status?: string): Promise<void> {
const messageId = await this.resolveTypingTargetMessageId(threadId);
this.logger.debug("WhatsApp typing indicator requested", {
messageId,
threadId,
});

if (!messageId) {
this.logger.warn(
"WhatsApp typing indicator skipped - no inbound message context",
{ threadId }
);
return;
}

if (status) {
this.logger.warn("WhatsApp typing indicator ignores custom status text", {
status,
threadId,
messageId,
});
}

const response =
await this.graphApiRequest<WhatsAppTypingIndicatorResponse>(
`/${this.phoneNumberId}/messages`,
{
messaging_product: "whatsapp",
status: "read",
message_id: messageId,
typing_indicator: {
type: "text",
},
}
);

if (!response.success) {
this.logger.error(
"WhatsApp typing indicator failed: API returned success=false",
{
messageId,
threadId,
}
);
throw new AdapterError("WhatsApp typing indicator failed", "whatsapp");
}
}

/**
Expand Down Expand Up @@ -1125,6 +1175,29 @@ export class WhatsAppAdapter
// Private helpers
// =============================================================================

/**
* Resolve the latest inbound message ID for a thread.
*/
private async resolveTypingTargetMessageId(
threadId: string
): Promise<string | null> {
if (!this.chat) {
return null;
}

const state = this.chat.getState();
const history = await new MessageHistoryCache(state).getMessages(threadId);

for (let index = history.length - 1; index >= 0; index--) {
const message = history[index];
if (message && !message.author.isMe) {
return message.id;
}
}

return null;
}

/**
* Make a request to the Meta Graph API.
*/
Expand All @@ -1148,7 +1221,10 @@ export class WhatsAppAdapter
body: errorBody,
path,
});
throw new Error(`WhatsApp API error: ${response.status} ${errorBody}`);
throw new AdapterError(
`WhatsApp API error: ${response.status} ${errorBody}`,
"whatsapp"
);
}

return response.json() as Promise<T>;
Expand Down
9 changes: 8 additions & 1 deletion packages/adapter-whatsapp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { Logger } from "chat";
export interface WhatsAppAdapterConfig {
/** Access token (System User token) for WhatsApp Cloud API calls */
accessToken: string;
/** Meta Graph API version (default: "v21.0") */
/** Meta Graph API version (default: "v25.0") */
apiVersion?: string;
/** Meta App Secret for webhook HMAC-SHA256 signature verification */
appSecret: string;
Expand Down Expand Up @@ -266,6 +266,13 @@ export interface WhatsAppSendResponse {
messaging_product: "whatsapp";
}

/**
* Response from sending a typing indicator via the Cloud API.
*/
export interface WhatsAppTypingIndicatorResponse {
success: boolean;
}

/**
* Interactive message payload for sending buttons or lists.
*/
Expand Down