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 e0ca5a39..c25a5623 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/apps/docs/content/docs/adapters/whatsapp.mdx b/apps/docs/content/docs/adapters/whatsapp.mdx index 3cc98681..1f05fd0f 100644 --- a/apps/docs/content/docs/adapters/whatsapp.mdx +++ b/apps/docs/content/docs/adapters/whatsapp.mdx @@ -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")`) | 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 711c8879..11964f29 100644 --- a/packages/adapter-whatsapp/src/index.test.ts +++ b/packages/adapter-whatsapp/src/index.test.ts @@ -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(); }); }); diff --git a/packages/adapter-whatsapp/src/index.ts b/packages/adapter-whatsapp/src/index.ts index ee5de610..3775fcba 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; @@ -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 { - // 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"); + } } /** @@ -1125,6 +1175,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. */ @@ -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; diff --git a/packages/adapter-whatsapp/src/types.ts b/packages/adapter-whatsapp/src/types.ts index 117734f9..667e7f50 100644 --- a/packages/adapter-whatsapp/src/types.ts +++ b/packages/adapter-whatsapp/src/types.ts @@ -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; @@ -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. */