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
5 changes: 5 additions & 0 deletions .changeset/whatsapp-empty-phone-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/whatsapp": patch
---

Treat empty `from` and `wa_id` values as absent, so messages from username users who hide their phone number reach handlers instead of being dropped.
3 changes: 2 additions & 1 deletion packages/adapter-whatsapp/sample-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@

Username-enabled users carry a BSUID (`user_id`/`from_user_id`) and
optionally a parent BSUID. Phone-based fields (`from`, `wa_id`) are
omitted when the user has not shared their phone number.
omitted, or sent as empty strings, when the user has not shared their
phone number.

```json
{
Expand Down
69 changes: 69 additions & 0 deletions packages/adapter-whatsapp/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,75 @@ describe("handleWebhook - business-scoped user IDs", () => {
}
});

it("treats empty phone fields as absent for username users", async () => {
const adapter = createTestAdapter();
const chat = createMockChatInstance();
await adapter.initialize(chat);
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify({ messages: [{ id: "wamid.reply" }] }), {
status: 200,
headers: { "content-type": "application/json" },
})
);

// Meta can send `from` and `wa_id` as empty strings, instead of omitting
// them, when a username user does not share their phone number.
const response = await adapter.handleWebhook(
webhook(
notification(
[inbound({ from: "", from_user_id: "US.13491208655302741918" })],
[
{
profile: { name: "User", username: "@exampleuser" },
wa_id: "",
user_id: "US.13491208655302741918",
},
]
)
)
);

expect(response.status).toBe(200);
expect(chat.processMessage).toHaveBeenCalledOnce();
const [, threadId] = vi.mocked(chat.processMessage).mock.calls[0];
expect(threadId).toBe("whatsapp:123456789:US.13491208655302741918");

await adapter.postMessage(threadId, { markdown: "Reply" });
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.recipient).toBe("US.13491208655302741918");
expect(body).not.toHaveProperty("to");
fetchSpy.mockRestore();
});

it("treats an empty system message sender as absent", async () => {
const adapter = createTestAdapter();
const chat = createMockChatInstance();
await adapter.initialize(chat);

await adapter.handleWebhook(
webhook(
notification([
{
from: "",
id: "wamid.system",
timestamp: "1700000001",
type: "system",
system: {
body: "User A changed their business-scoped user ID",
wa_id: "",
user_id: "US.NEW",
type: "user_changed_number",
},
},
])
)
);

await expect(
chat.getState().get("whatsapp:identity:alias:123456789:US.NEW")
).resolves.toBe("US.NEW");
});

it("sends both known identifiers from a preserved phone thread", async () => {
const adapter = createTestAdapter();
const chat = createMockChatInstance();
Expand Down
20 changes: 14 additions & 6 deletions packages/adapter-whatsapp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,13 +755,21 @@ export class WhatsAppAdapter
inbound: WhatsAppInboundMessage,
contact?: WhatsAppContact
): WhatsAppIdentity | null {
const phone = inbound.system?.wa_id ?? inbound.from ?? contact?.wa_id;
// `||` rather than `??`: Meta can send an absent identifier as an
// empty string, e.g. `from` and `wa_id` for a username user who hides
// their phone number.
const phone =
inbound.system?.wa_id || inbound.from || contact?.wa_id || undefined;
const bsuid =
inbound.system?.user_id ?? inbound.from_user_id ?? contact?.user_id;
inbound.system?.user_id ||
inbound.from_user_id ||
contact?.user_id ||
undefined;
const parent =
inbound.system?.parent_user_id ??
inbound.from_parent_user_id ??
contact?.parent_user_id;
inbound.system?.parent_user_id ||
inbound.from_parent_user_id ||
contact?.parent_user_id ||
undefined;
const userId = phone ?? bsuid ?? parent;

return userId ? { bsuid, parent, phone, userId } : null;
Expand All @@ -782,7 +790,7 @@ export class WhatsAppAdapter
// A system message's `from` carries the pre-change identifier, so
// prefer it as the canonical fallback — a thread that predates any
// alias state keeps its original key that way.
const source = changed ? inbound.from : undefined;
const source = changed ? inbound.from || undefined : undefined;
const fallback = source ?? identity.userId;

if (!this.chat) {
Expand Down
Loading