Skip to content

Commit 104df5e

Browse files
committed
feat(whatsapp-adapter): Add typing indicator support and update API to v25.0
1 parent e8c4b1a commit 104df5e

6 files changed

Lines changed: 191 additions & 16 deletions

File tree

.changeset/tiny-ways-switch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@chat-adapter/whatsapp": minor
3+
---
4+
5+
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.

apps/docs/content/docs/adapters.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Ready to build your own? Follow the [building](/docs/contributing/building) guid
4444
| Mentions | <Check /> | <Check /> | <Check /> | <Check /> | <Check /> | <Check /> | <Check /> | <Cross /> |
4545
| Add reactions | <Check /> | <Cross /> | <Check /> | <Check /> | <Check /> | <Check /> | <Check /> | <Cross /> |
4646
| Remove reactions | <Check /> | <Cross /> | <Check /> | <Check /> | <Check /> | <Warn /> | <Warn /> | <Cross /> |
47-
| Typing indicator | <Cross /> | <Check /> | <Cross /> | <Check /> | <Check /> | <Cross /> | <Cross /> | <Cross /> |
47+
| Typing indicator | <Cross /> | <Check /> | <Cross /> | <Check /> | <Check /> | <Cross /> | <Cross /> | <Warn /> |
4848
| DMs | <Check /> | <Check /> | <Check /> | <Check /> | <Check /> | <Cross /> | <Cross /> | <Check /> |
4949
| Ephemeral messages | <Check /> Native | <Cross /> | <Check /> Native | <Cross /> | <Cross /> | <Cross /> | <Cross /> | <Cross /> |
5050

packages/adapter-whatsapp/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ All options are auto-detected from environment variables when not provided. You
6767
| `appSecret` | No* | App secret for webhook verification. Auto-detected from `WHATSAPP_APP_SECRET` |
6868
| `phoneNumberId` | No* | Bot's phone number ID. Auto-detected from `WHATSAPP_PHONE_NUMBER_ID` |
6969
| `verifyToken` | No* | Webhook verification secret. Auto-detected from `WHATSAPP_VERIFY_TOKEN` |
70-
| `apiVersion` | No | Graph API version (defaults to `v21.0`) |
70+
| `apiVersion` | No | Graph API version (defaults to `v25.0`) |
7171
| `userName` | No | Bot username for self-message detection. Auto-detected from `WHATSAPP_BOT_USERNAME` (defaults to `whatsapp-bot`) |
7272
| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) |
7373

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

packages/adapter-whatsapp/src/index.test.ts

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -949,11 +949,98 @@ describe("addReaction / removeReaction", () => {
949949
// ---------------------------------------------------------------------------
950950

951951
describe("startTyping", () => {
952-
it("is a no-op and does not throw", async () => {
952+
let fetchSpy: MockInstance;
953+
954+
const makeGraphApiResponse = () =>
955+
new Response(JSON.stringify({ success: true }), {
956+
status: 200,
957+
headers: { "Content-Type": "application/json" },
958+
});
959+
960+
beforeEach(() => {
961+
fetchSpy = vi
962+
.spyOn(global, "fetch")
963+
.mockImplementation(() => Promise.resolve(makeGraphApiResponse()));
964+
});
965+
966+
afterEach(() => {
967+
fetchSpy.mockRestore();
968+
});
969+
970+
it("resolves latest inbound message ID and sends typing indicator", async () => {
953971
const adapter = createTestAdapter();
954-
await expect(
955-
adapter.startTyping("whatsapp:123456789:15551234567")
956-
).resolves.toBeUndefined();
972+
const threadId = "whatsapp:123456789:15551234567";
973+
974+
// Mock history: 1 inbound message, 1 outbound (bot) message
975+
const mockState = {
976+
getList: vi.fn().mockResolvedValue([
977+
{
978+
_type: "chat:Message",
979+
id: "wamid.inbound123",
980+
threadId,
981+
text: "Hi",
982+
author: {
983+
userId: "15551234567",
984+
userName: "User",
985+
fullName: "User",
986+
isMe: false,
987+
isBot: false,
988+
},
989+
formatted: { type: "root", children: [] },
990+
attachments: [],
991+
metadata: { dateSent: new Date().toISOString(), edited: false },
992+
},
993+
{
994+
_type: "chat:Message",
995+
id: "wamid.outbound456",
996+
threadId,
997+
text: "Hello",
998+
author: {
999+
userId: "123456789",
1000+
userName: "bot",
1001+
fullName: "bot",
1002+
isMe: true,
1003+
isBot: true,
1004+
},
1005+
formatted: { type: "root", children: [] },
1006+
attachments: [],
1007+
metadata: { dateSent: new Date().toISOString(), edited: false },
1008+
},
1009+
]),
1010+
};
1011+
1012+
await adapter.initialize({
1013+
...mockChat,
1014+
getState: () => mockState,
1015+
} as any);
1016+
1017+
await adapter.startTyping(threadId);
1018+
1019+
expect(fetchSpy).toHaveBeenCalledOnce();
1020+
const [url, init] = fetchSpy.mock.calls[0];
1021+
expect(String(url)).toContain("/123456789/messages");
1022+
const sent = JSON.parse(init?.body as string);
1023+
expect(sent.status).toBe("read");
1024+
expect(sent.message_id).toBe("wamid.inbound123");
1025+
expect(sent.typing_indicator.type).toBe("text");
1026+
});
1027+
1028+
it("does nothing if no inbound message is found in history", async () => {
1029+
const adapter = createTestAdapter();
1030+
const threadId = "whatsapp:123456789:15551234567";
1031+
1032+
const mockState = {
1033+
getList: vi.fn().mockResolvedValue([]),
1034+
};
1035+
1036+
await adapter.initialize({
1037+
...mockChat,
1038+
getState: () => mockState,
1039+
} as any);
1040+
1041+
await adapter.startTyping(threadId);
1042+
1043+
expect(fetchSpy).not.toHaveBeenCalled();
9571044
});
9581045
});
9591046

packages/adapter-whatsapp/src/index.ts

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { createHmac, timingSafeEqual } from "node:crypto";
2-
import { extractCard, ValidationError } from "@chat-adapter/shared";
2+
import {
3+
AdapterError,
4+
extractCard,
5+
ValidationError,
6+
} from "@chat-adapter/shared";
37
import type {
48
Adapter,
59
AdapterPostableMessage,
@@ -24,6 +28,7 @@ import {
2428
defaultEmojiResolver,
2529
getEmoji,
2630
Message,
31+
MessageHistoryCache,
2732
} from "chat";
2833
import { cardToWhatsApp, decodeWhatsAppCallbackData } from "./cards";
2934
import { WhatsAppFormatConverter } from "./markdown";
@@ -36,11 +41,12 @@ import type {
3641
WhatsAppRawMessage,
3742
WhatsAppSendResponse,
3843
WhatsAppThreadId,
44+
WhatsAppTypingIndicatorResponse,
3945
WhatsAppWebhookPayload,
4046
} from "./types";
4147

4248
/** Default Graph API version */
43-
const DEFAULT_API_VERSION = "v21.0";
49+
const DEFAULT_API_VERSION = "v25.0";
4450

4551
/** Maximum message length for WhatsApp Cloud API */
4652
const WHATSAPP_MESSAGE_LIMIT = 4096;
@@ -968,13 +974,57 @@ export class WhatsAppAdapter
968974
/**
969975
* Start typing indicator.
970976
*
971-
* WhatsApp supports typing indicators via the messages endpoint.
972-
* The indicator displays for up to 25 seconds or until the next message.
977+
* WhatsApp typing indicators require the most recent inbound message ID.
978+
* They also implicitly mark the referenced message as read.
973979
*
974-
* @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/mark-messages-as-read
980+
* @see https://developers.facebook.com/documentation/business-messaging/whatsapp/typing-indicators
975981
*/
976-
async startTyping(_threadId: string, _status?: string): Promise<void> {
977-
// WhatsApp Cloud API does not support typing indicators.
982+
async startTyping(threadId: string, status?: string): Promise<void> {
983+
const messageId = await this.resolveTypingTargetMessageId(threadId);
984+
this.logger.debug("WhatsApp typing indicator requested", {
985+
messageId,
986+
threadId,
987+
});
988+
989+
if (!messageId) {
990+
this.logger.warn(
991+
"WhatsApp typing indicator skipped - no inbound message context",
992+
{ threadId }
993+
);
994+
return;
995+
}
996+
997+
if (status) {
998+
this.logger.warn("WhatsApp typing indicator ignores custom status text", {
999+
status,
1000+
threadId,
1001+
messageId,
1002+
});
1003+
}
1004+
1005+
const response =
1006+
await this.graphApiRequest<WhatsAppTypingIndicatorResponse>(
1007+
`/${this.phoneNumberId}/messages`,
1008+
{
1009+
messaging_product: "whatsapp",
1010+
status: "read",
1011+
message_id: messageId,
1012+
typing_indicator: {
1013+
type: "text",
1014+
},
1015+
}
1016+
);
1017+
1018+
if (!response.success) {
1019+
this.logger.error(
1020+
"WhatsApp typing indicator failed: API returned success=false",
1021+
{
1022+
messageId,
1023+
threadId,
1024+
}
1025+
);
1026+
throw new AdapterError("WhatsApp typing indicator failed", "whatsapp");
1027+
}
9781028
}
9791029

9801030
/**
@@ -1138,6 +1188,29 @@ export class WhatsAppAdapter
11381188
// Private helpers
11391189
// =============================================================================
11401190

1191+
/**
1192+
* Resolve the latest inbound message ID for a thread.
1193+
*/
1194+
private async resolveTypingTargetMessageId(
1195+
threadId: string
1196+
): Promise<string | null> {
1197+
if (!this.chat) {
1198+
return null;
1199+
}
1200+
1201+
const state = this.chat.getState();
1202+
const history = await new MessageHistoryCache(state).getMessages(threadId);
1203+
1204+
for (let index = history.length - 1; index >= 0; index--) {
1205+
const message = history[index];
1206+
if (message && !message.author.isMe) {
1207+
return message.id;
1208+
}
1209+
}
1210+
1211+
return null;
1212+
}
1213+
11411214
/**
11421215
* Make a request to the Meta Graph API.
11431216
*/
@@ -1161,7 +1234,10 @@ export class WhatsAppAdapter
11611234
body: errorBody,
11621235
path,
11631236
});
1164-
throw new Error(`WhatsApp API error: ${response.status} ${errorBody}`);
1237+
throw new AdapterError(
1238+
`WhatsApp API error: ${response.status} ${errorBody}`,
1239+
"whatsapp"
1240+
);
11651241
}
11661242

11671243
return response.json() as Promise<T>;

packages/adapter-whatsapp/src/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface WhatsAppAdapterConfig {
2424
accessToken: string;
2525
/** Override the Meta Graph API base URL (e.g. for on-premise deployments). Defaults to "https://graph.facebook.com". */
2626
apiUrl?: string;
27-
/** Meta Graph API version (default: "v21.0") */
27+
/** Meta Graph API version (default: "v25.0") */
2828
apiVersion?: string;
2929
/** Meta App Secret for webhook HMAC-SHA256 signature verification */
3030
appSecret: string;
@@ -268,6 +268,13 @@ export interface WhatsAppSendResponse {
268268
messaging_product: "whatsapp";
269269
}
270270

271+
/**
272+
* Response from sending a typing indicator via the Cloud API.
273+
*/
274+
export interface WhatsAppTypingIndicatorResponse {
275+
success: boolean;
276+
}
277+
271278
/**
272279
* Interactive message payload for sending buttons or lists.
273280
*/

0 commit comments

Comments
 (0)