diff --git a/.changeset/tiny-ways-switch.md b/.changeset/tiny-ways-switch.md
new file mode 100644
index 00000000..5ad3714b
--- /dev/null
+++ b/.changeset/tiny-ways-switch.md
@@ -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.
diff --git a/apps/docs/content/docs/adapters.mdx b/apps/docs/content/docs/adapters.mdx
index 9cabcd91..1aa101b1 100644
--- a/apps/docs/content/docs/adapters.mdx
+++ b/apps/docs/content/docs/adapters.mdx
@@ -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 | | | | | |
diff --git a/packages/adapter-whatsapp/README.md b/packages/adapter-whatsapp/README.md
index 7c8e9fa5..0bf68c96 100644
--- a/packages/adapter-whatsapp/README.md
+++ b/packages/adapter-whatsapp/README.md
@@ -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")`) |
@@ -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 |
diff --git a/packages/adapter-whatsapp/src/index.test.ts b/packages/adapter-whatsapp/src/index.test.ts
index 5f5c3b83..4c81d0e5 100644
--- a/packages/adapter-whatsapp/src/index.test.ts
+++ b/packages/adapter-whatsapp/src/index.test.ts
@@ -949,11 +949,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();
});
});
diff --git a/packages/adapter-whatsapp/src/index.ts b/packages/adapter-whatsapp/src/index.ts
index f0b37fe7..fb9f08f3 100644
--- a/packages/adapter-whatsapp/src/index.ts
+++ b/packages/adapter-whatsapp/src/index.ts
@@ -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,
@@ -24,6 +28,7 @@ import {
defaultEmojiResolver,
getEmoji,
Message,
+ MessageHistoryCache,
} from "chat";
import { cardToWhatsApp, decodeWhatsAppCallbackData } from "./cards";
import { WhatsAppFormatConverter } from "./markdown";
@@ -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;
@@ -968,13 +974,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 {
- // WhatsApp Cloud API does not support typing indicators.
+ async startTyping(threadId: string, status?: string): Promise {
+ 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(
+ `/${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");
+ }
}
/**
@@ -1138,6 +1188,29 @@ export class WhatsAppAdapter
// Private helpers
// =============================================================================
+ /**
+ * Resolve the latest inbound message ID for a thread.
+ */
+ private async resolveTypingTargetMessageId(
+ threadId: string
+ ): Promise {
+ 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.
*/
@@ -1161,7 +1234,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;
diff --git a/packages/adapter-whatsapp/src/types.ts b/packages/adapter-whatsapp/src/types.ts
index d8d711cc..2d60d55f 100644
--- a/packages/adapter-whatsapp/src/types.ts
+++ b/packages/adapter-whatsapp/src/types.ts
@@ -24,7 +24,7 @@ export interface WhatsAppAdapterConfig {
accessToken: string;
/** Override the Meta Graph API base URL (e.g. for on-premise deployments). Defaults to "https://graph.facebook.com". */
apiUrl?: 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;
@@ -268,6 +268,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.
*/