From d9140e1cf205885325a7c63c8ba92094a521cc19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 03:55:00 +0000 Subject: [PATCH] feat(whatsapp): send native location request from get user data Co-authored-by: JuanBifrost --- apps/builder/messages/ar.json | 3 +- apps/builder/messages/da.json | 3 +- apps/builder/messages/de.json | 3 +- apps/builder/messages/en.json | 3 +- apps/builder/messages/es.json | 3 +- apps/builder/messages/fi.json | 3 +- apps/builder/messages/fr.json | 3 +- apps/builder/messages/he.json | 3 +- apps/builder/messages/id.json | 3 +- apps/builder/messages/it.json | 3 +- apps/builder/messages/ja.json | 3 +- apps/builder/messages/nl.json | 3 +- apps/builder/messages/pt-BR.json | 3 +- apps/builder/messages/pt-PT.json | 3 +- apps/builder/messages/ro.json | 3 +- apps/builder/messages/sv.json | 3 +- apps/builder/messages/tr.json | 3 +- apps/builder/messages/vi.json | 3 +- apps/builder/messages/zh-CN.json | 3 +- apps/builder/messages/zh-TW.json | 3 +- .../react-flow/steps/get-user-data/editor.tsx | 5 + apps/worker/__tests__/get-user-data.test.ts | 105 ++++++++++++++++ .../src/integration/handlers/get-user-data.ts | 85 +++++++++++-- .../outgoing-location-request.test.ts | 113 ++++++++++++++++++ .../message/outgoing-message/index.ts | 70 ++++++++--- integrations/whatsapp/src/schema.ts | 22 +++- .../__tests__/reply-format-validators.test.ts | 40 ++++++- .../get-user-data/reply-format-validators.ts | 12 +- .../get-user-data/reply-input.combinators.ts | 31 +++++ packages/sdk/__tests__/message.test.ts | 40 +++++++ packages/sdk/src/lib/shared/message.ts | 33 +++++ 31 files changed, 567 insertions(+), 49 deletions(-) create mode 100644 integrations/whatsapp/__tests__/outgoing-location-request.test.ts diff --git a/apps/builder/messages/ar.json b/apps/builder/messages/ar.json index 03bb117fd7..eed3bfb0c0 100644 --- a/apps/builder/messages/ar.json +++ b/apps/builder/messages/ar.json @@ -1781,7 +1781,8 @@ "date": "التاريخ", "datetime": "التاريخ والوقت", "location": "الموقع", - "anyInput": "أي إدخال" + "anyInput": "أي إدخال", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "عضو مساحة العمل" diff --git a/apps/builder/messages/da.json b/apps/builder/messages/da.json index ad41c93cf0..380fa94641 100644 --- a/apps/builder/messages/da.json +++ b/apps/builder/messages/da.json @@ -1644,7 +1644,8 @@ "date": "Dato", "datetime": "Datetime", "location": "Placering", - "anyInput": "Vilkårlig Input" + "anyInput": "Vilkårlig Input", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Arbejdsområde Member" diff --git a/apps/builder/messages/de.json b/apps/builder/messages/de.json index 2650d82d70..e09b9b3d7e 100644 --- a/apps/builder/messages/de.json +++ b/apps/builder/messages/de.json @@ -1644,7 +1644,8 @@ "date": "Datum", "datetime": "Datum und Uhrzeit", "location": "Standort", - "anyInput": "Beliebige Eingabe" + "anyInput": "Beliebige Eingabe", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Arbeitsbereichsmitglied" diff --git a/apps/builder/messages/en.json b/apps/builder/messages/en.json index 2480c2c252..22502fb02b 100644 --- a/apps/builder/messages/en.json +++ b/apps/builder/messages/en.json @@ -1790,7 +1790,8 @@ "date": "Date", "datetime": "Datetime", "location": "Location", - "anyInput": "Any Input" + "anyInput": "Any Input", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Workspace Member" diff --git a/apps/builder/messages/es.json b/apps/builder/messages/es.json index 758d519f41..167ceccaa3 100644 --- a/apps/builder/messages/es.json +++ b/apps/builder/messages/es.json @@ -1781,7 +1781,8 @@ "date": "Fecha", "datetime": "Fechahora", "location": "Ubicación", - "anyInput": "Any Entrada" + "anyInput": "Any Entrada", + "locationDescription": "En WhatsApp envía el botón nativo Enviar ubicación de Meta y espera el pin. En otros canales envía el texto; un pin compartido o lat,lng escrito también completa el paso." }, "workspaceMember": { "label": "Espacio de trabajo Miembro" diff --git a/apps/builder/messages/fi.json b/apps/builder/messages/fi.json index cfd0a650fe..f8e44c5113 100644 --- a/apps/builder/messages/fi.json +++ b/apps/builder/messages/fi.json @@ -1644,7 +1644,8 @@ "date": "Päivämäärä", "datetime": "Päivämäärä ja aika", "location": "Sijainti", - "anyInput": "Mikä tahansa syöte" + "anyInput": "Mikä tahansa syöte", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Työtilan jäsen" diff --git a/apps/builder/messages/fr.json b/apps/builder/messages/fr.json index 23abc21593..628205c5e7 100644 --- a/apps/builder/messages/fr.json +++ b/apps/builder/messages/fr.json @@ -1644,7 +1644,8 @@ "date": "Date", "datetime": "Date et heure", "location": "Localisation", - "anyInput": "Toute saisie" + "anyInput": "Toute saisie", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Membre de l’espace de travail" diff --git a/apps/builder/messages/he.json b/apps/builder/messages/he.json index 640fdc6b02..9feba4e5b1 100644 --- a/apps/builder/messages/he.json +++ b/apps/builder/messages/he.json @@ -4336,7 +4336,8 @@ "date": "תאריך", "datetime": "תאריך ושעה", "location": "מיקום", - "anyInput": "כל קלט" + "anyInput": "כל קלט", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "חבר בסביבת העבודה" diff --git a/apps/builder/messages/id.json b/apps/builder/messages/id.json index 73a650a651..fbd3f7e48c 100644 --- a/apps/builder/messages/id.json +++ b/apps/builder/messages/id.json @@ -1644,7 +1644,8 @@ "date": "Tanggal", "datetime": "Tanggal dan waktu", "location": "Lokasi", - "anyInput": "Input Apa Pun" + "anyInput": "Input Apa Pun", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Anggota Workspace" diff --git a/apps/builder/messages/it.json b/apps/builder/messages/it.json index 9d19b079d3..7119acf887 100644 --- a/apps/builder/messages/it.json +++ b/apps/builder/messages/it.json @@ -957,7 +957,8 @@ "date": "Data", "datetime": "Data e ora", "location": "Posizione", - "anyInput": "Qualsiasi input" + "anyInput": "Qualsiasi input", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Membro dell'area di lavoro" diff --git a/apps/builder/messages/ja.json b/apps/builder/messages/ja.json index 57481216b4..fc47772a62 100644 --- a/apps/builder/messages/ja.json +++ b/apps/builder/messages/ja.json @@ -1644,7 +1644,8 @@ "date": "日付", "datetime": "日時", "location": "位置情報", - "anyInput": "任意の入力" + "anyInput": "任意の入力", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "ワークスペースメンバー" diff --git a/apps/builder/messages/nl.json b/apps/builder/messages/nl.json index 43f5949080..9cc098c44b 100644 --- a/apps/builder/messages/nl.json +++ b/apps/builder/messages/nl.json @@ -1457,7 +1457,8 @@ "date": "Datum", "datetime": "Datum en tijd", "location": "Locatie", - "anyInput": "Elke invoer" + "anyInput": "Elke invoer", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Werkruimtelid" diff --git a/apps/builder/messages/pt-BR.json b/apps/builder/messages/pt-BR.json index 915121ba24..da063d151b 100644 --- a/apps/builder/messages/pt-BR.json +++ b/apps/builder/messages/pt-BR.json @@ -1644,7 +1644,8 @@ "date": "Data", "datetime": "Data e hora", "location": "Localização", - "anyInput": "Qualquer entrada" + "anyInput": "Qualquer entrada", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Membro do espaço de trabalho" diff --git a/apps/builder/messages/pt-PT.json b/apps/builder/messages/pt-PT.json index 1ea43581f9..4c65928931 100644 --- a/apps/builder/messages/pt-PT.json +++ b/apps/builder/messages/pt-PT.json @@ -1644,7 +1644,8 @@ "date": "Data", "datetime": "Data e hora", "location": "Localização", - "anyInput": "Qualquer entrada" + "anyInput": "Qualquer entrada", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Membro do espaço de trabalho" diff --git a/apps/builder/messages/ro.json b/apps/builder/messages/ro.json index 1a14de9a76..b4218093b2 100644 --- a/apps/builder/messages/ro.json +++ b/apps/builder/messages/ro.json @@ -2428,7 +2428,8 @@ "date": "Dată", "datetime": "Dată și oră", "location": "Locație", - "anyInput": "Orice intrare" + "anyInput": "Orice intrare", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Membru al spațiului de lucru" diff --git a/apps/builder/messages/sv.json b/apps/builder/messages/sv.json index cb8b661602..e69b9091fb 100644 --- a/apps/builder/messages/sv.json +++ b/apps/builder/messages/sv.json @@ -2528,7 +2528,8 @@ "location": "Plats", "number": "Nummer", "phone": "Telefon", - "text": "Text" + "text": "Text", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "requestBody": { "keyPlaceholder": "Fält", diff --git a/apps/builder/messages/tr.json b/apps/builder/messages/tr.json index c3963dd513..6948ab327e 100644 --- a/apps/builder/messages/tr.json +++ b/apps/builder/messages/tr.json @@ -1781,7 +1781,8 @@ "date": "Tarih", "datetime": "Tarih ve Saat", "location": "Konum", - "anyInput": "Herhangi Bir Girdi" + "anyInput": "Herhangi Bir Girdi", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "Çalışma Alanı Üyesi" diff --git a/apps/builder/messages/vi.json b/apps/builder/messages/vi.json index fec2770470..117db47b42 100644 --- a/apps/builder/messages/vi.json +++ b/apps/builder/messages/vi.json @@ -1748,7 +1748,8 @@ "date": "Ngày", "datetime": "Ngày giờ", "location": "Vị trí", - "anyInput": "Mọi định dạng" + "anyInput": "Mọi định dạng", + "locationDescription": "Trên WhatsApp, bước này gửi nút Gửi vị trí gốc của Meta và chờ ghim. Kênh khác gửi lời nhắc dạng chữ; ghim hoặc lat,lng vẫn hoàn tất bước." }, "workspaceMember": { "label": "Thành viên workspace" diff --git a/apps/builder/messages/zh-CN.json b/apps/builder/messages/zh-CN.json index 6ed74b47bc..274f892f7f 100644 --- a/apps/builder/messages/zh-CN.json +++ b/apps/builder/messages/zh-CN.json @@ -2531,7 +2531,8 @@ "location": "位置", "number": "号码", "phone": "电话", - "text": "文本" + "text": "文本", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "requestBody": { "keyPlaceholder": "字段", diff --git a/apps/builder/messages/zh-TW.json b/apps/builder/messages/zh-TW.json index 70bfae8367..764b80bf42 100644 --- a/apps/builder/messages/zh-TW.json +++ b/apps/builder/messages/zh-TW.json @@ -957,7 +957,8 @@ "date": "日期", "datetime": "日期時間", "location": "位置", - "anyInput": "任何輸入" + "anyInput": "任何輸入", + "locationDescription": "On WhatsApp this sends Meta's native Send location button and waits for the pin. Other channels send the prompt as text; a shared pin or typed lat,lng still completes the step." }, "workspaceMember": { "label": "工作區成員" diff --git a/apps/builder/src/features/flows/react-flow/steps/get-user-data/editor.tsx b/apps/builder/src/features/flows/react-flow/steps/get-user-data/editor.tsx index 630ab4b42c..f6ecf8d264 100644 --- a/apps/builder/src/features/flows/react-flow/steps/get-user-data/editor.tsx +++ b/apps/builder/src/features/flows/react-flow/steps/get-user-data/editor.tsx @@ -106,6 +106,11 @@ const GetUserDataStepForm = ({ onSubmit={form.handleSubmit(onSubmit)} > { }) }) + describe("location format", () => { + test("location pin → returns success with lat,lng", async () => { + lastMessage.current = makeIncomingMessage({ + contentType: "location", + text: "Received location", + contentAttributes: { latitude: 10.5, longitude: 106.75 }, + }) + + const result = await getUserData(makeProps(ReplyFormat.location)) + + expect(result.status).toBe("success") + expectCustomFieldWrite("10.5,106.75") + }) + + test("typed coordinate pair → returns success", async () => { + lastMessage.current = makeIncomingMessage({ + text: "10.5, 106.75", + }) + + const result = await getUserData(makeProps(ReplyFormat.location)) + + expect(result.status).toBe("success") + expectCustomFieldWrite("10.5,106.75") + }) + + test("plain text without coordinates → returns retry", async () => { + lastMessage.current = makeIncomingMessage({ text: "Received location" }) + + const result = await getUserData(makeProps(ReplyFormat.location)) + + expect(result.status).toBe("retry") + expect(contactCustomFieldSetValueByKey).not.toHaveBeenCalled() + }) + }) + describe("no message", () => { test("no last message → returns retry", async () => { lastMessage.current = null @@ -853,12 +888,14 @@ function findChatJobCall(action: string) { return call[1] as { type: string data: { + text?: string quickReplies?: { id: string label: string buttonType: string url?: string messengerExtensions?: boolean + postback?: string }[] } } @@ -1327,3 +1364,71 @@ describe("getUserData — non-date replyFormats keep the text prompt path (regre ) }) }) + +describe("getUserData — WhatsApp native location request (RF08)", () => { + beforeEach(() => { + chatQueueAdd.mockClear() + }) + + test("whatsapp location format sends the reserved native location-request marker", async () => { + const props = makeProps(ReplyFormat.location, { + message: "Please share your location", + }) + props.ctx = { variables: { conversation: {} } } + props.contactInbox = { ...props.contactInbox, channel: "whatsapp" } + + const result = await getUserData(props) + + expect(result.status).toBe("wait") + const job = findChatJobCall("sendChatMessage") + expect(job.data).toMatchObject({ + text: "Please share your location", + quickReplies: [ + { + id: "whatsapp:native:location_request", + label: "Send location", + buttonType: "postback", + postback: "whatsapp:native:location_request", + }, + ], + }) + expect(chatQueueAdd).not.toHaveBeenCalledWith( + "sendFlowMessage", + expect.anything(), + ) + expect(waitForChatJobCompletion).toHaveBeenCalledWith( + expect.objectContaining({ id: "job-1" }), + { conversationId: "conv-1", stepId: "step-1" }, + ) + }) + + test("uses the Vietnamese inbox label when workspace.language is vi", async () => { + workspaceFindById.mockResolvedValueOnce({ language: "vi" }) + const props = makeProps(ReplyFormat.location) + props.ctx = { variables: { conversation: {} } } + props.contactInbox = { ...props.contactInbox, channel: "whatsapp" } + + await getUserData(props) + + const job = findChatJobCall("sendChatMessage") + expect(job.data.quickReplies?.[0]?.label).toBe("Gửi vị trí") + }) + + test("non-whatsapp location format keeps the text prompt path", async () => { + const props = makeProps(ReplyFormat.location) + props.ctx = { variables: { conversation: {} } } + props.contactInbox = { ...props.contactInbox, channel: "messenger" } + + const result = await getUserData(props) + + expect(result.status).toBe("wait") + expect(chatQueueAdd).toHaveBeenCalledWith( + "sendFlowMessage", + expect.objectContaining({ type: "sendFlowMessage" }), + ) + expect(chatQueueAdd).not.toHaveBeenCalledWith( + "sendChatMessage", + expect.anything(), + ) + }) +}) diff --git a/apps/worker/src/integration/handlers/get-user-data.ts b/apps/worker/src/integration/handlers/get-user-data.ts index f45d16c855..204e85c4f3 100644 --- a/apps/worker/src/integration/handlers/get-user-data.ts +++ b/apps/worker/src/integration/handlers/get-user-data.ts @@ -30,13 +30,16 @@ import { } from "@chatbotx.io/flow-config" import { IntegrationException, + NATIVE_LOCATION_REQUEST_CHANNELS, URL_QUICK_REPLY_CAPABLE_CHANNELS, type Variable, + WHATSAPP_NATIVE_LOCATION_REQUEST, } from "@chatbotx.io/sdk" import { createId } from "@chatbotx.io/utils" import { ChatJobAction, chatQueue } from "@chatbotx.io/worker-config" import { add, isBefore } from "date-fns" import { logger } from "../../lib/logger" +import { waitForChatJobCompletion } from "../utils/message" import type { ExecuteStepProps } from "./flow" import { enqueueFlowStepMessage } from "./flow-utils" import type { ExecuteStepResult } from "./step" @@ -344,15 +347,17 @@ async function handleSkipOrError( * is silently dropped by the send path. For the webview formats * (date/datetime) a blank retry falls back to the step's main message so the * retry always re-offers the picker button — a silent retry would strand the - * contact with no way back to the picker. Every other reply format keeps the - * long-standing behavior (blank retry sends nothing): flows built before - * this feature may rely on that silence, and typed input still works there. + * contact with no way back to the picker. Location (RF08) does the same so + * WhatsApp always re-sends Cloud API's native location-request button. + * Every other reply format keeps the long-standing behavior (blank retry + * sends nothing): flows built before this feature may rely on that silence, + * and typed input still works there. */ function resolveRetryPromptText(step: GetUserDataStepSchema): string { - const isWebviewFormat = Boolean( - DATE_TIME_WEBVIEW_MODE_BY_REPLY_FORMAT[step.replyFormat], - ) - return isWebviewFormat + const isNativePromptFormat = + Boolean(DATE_TIME_WEBVIEW_MODE_BY_REPLY_FORMAT[step.replyFormat]) || + step.replyFormat === ReplyFormat.location + return isNativePromptFormat ? step.retryMessage.trim() || step.message : step.retryMessage } @@ -498,6 +503,14 @@ async function sendMessage( return } + if ( + step.replyFormat === ReplyFormat.location && + NATIVE_LOCATION_REQUEST_CHANNELS.has(contactInbox.channel) + ) { + await sendWhatsappLocationRequestPrompt(props, text) + return + } + const promptStep: SendTextStepSchema = { id: step.id, nodeId, @@ -584,6 +597,64 @@ async function sendDateTimePrompt( }) } +const LOCATION_REQUEST_COPY = { + en: { sendLocation: "Send location" }, + vi: { sendLocation: "Gửi vị trí" }, +} satisfies Record + +function getLocationRequestCopy(input: { + language?: string | null +}): { sendLocation: string } { + return normalizeLanguage(input.language) === "vi" + ? LOCATION_REQUEST_COPY.vi + : LOCATION_REQUEST_COPY.en +} + +/** + * Sends getUserData RF08 on WhatsApp as Cloud API + * `location_request_message` (native "Send location" button) via the + * reserved {@link WHATSAPP_NATIVE_LOCATION_REQUEST} quick reply. The + * WhatsApp outgoing converter swaps that marker for the Graph payload; + * other channels never emit it (see {@link NATIVE_LOCATION_REQUEST_CHANNELS}). + * Waits for delivery the same way the text-prompt path does, so the flow + * does not return `wait` before the contact can tap the button. + */ +async function sendWhatsappLocationRequestPrompt( + props: ExecuteStepProps, + text: string, +): Promise { + const { conversation, contactInbox, step, metadata } = props + + const workspace = await workspaceService.findById({ + id: conversation.workspaceId, + }) + const copy = getLocationRequestCopy({ language: workspace.language }) + + const job = await chatQueue.add(ChatJobAction.sendChatMessage, { + type: ChatJobAction.sendChatMessage, + data: { + conversation, + contactInbox, + text, + quickReplies: [ + { + id: WHATSAPP_NATIVE_LOCATION_REQUEST, + label: copy.sendLocation, + buttonType: "postback", + postback: WHATSAPP_NATIVE_LOCATION_REQUEST, + }, + ], + trackingContext: props.trackingContext, + metadata, + }, + }) + + await waitForChatJobCompletion(job, { + conversationId: conversation.id, + stepId: step.id, + }) +} + async function clearChallenge(props: { workspaceId: string conversationId: string diff --git a/integrations/whatsapp/__tests__/outgoing-location-request.test.ts b/integrations/whatsapp/__tests__/outgoing-location-request.test.ts new file mode 100644 index 0000000000..3a90056a6a --- /dev/null +++ b/integrations/whatsapp/__tests__/outgoing-location-request.test.ts @@ -0,0 +1,113 @@ +import { WHATSAPP_NATIVE_LOCATION_REQUEST } from "@chatbotx.io/sdk" +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { mockApiFetch, mockSendMessage, mockGetWhatsappClient } = vi.hoisted( + () => { + const apiFetch = vi.fn() + const sendMessageFn = vi.fn() + return { + mockApiFetch: apiFetch, + mockSendMessage: sendMessageFn, + mockGetWhatsappClient: vi.fn(() => ({ + $$apiFetch$$: apiFetch, + sendMessage: sendMessageFn, + })), + } + }, +) + +vi.mock("../src/client", () => ({ + getWhatsappClient: mockGetWhatsappClient, +})) + +vi.mock("../src/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +const { sendMessage } = await import( + "../src/handlers/message/outgoing-message" +) + +const PHONE_NUMBER_ID = "pn-1" +const ctx = { + auth: { metadata: { phoneNumber: { id: PHONE_NUMBER_ID } } }, +} as never + +const phoneKeyedContact = { + id: "contact-1", + sourceId: "84123456789", +} as never + +const locationRequestQuickReply = [ + { + id: WHATSAPP_NATIVE_LOCATION_REQUEST, + label: "Send location", + buttonType: "postback" as const, + postback: WHATSAPP_NATIVE_LOCATION_REQUEST, + }, +] + +const rawRequestBody = () => + JSON.parse((mockApiFetch.mock.calls[0][1] as RequestInit).body as string) as { + to?: string + type?: string + interactive?: { + type?: string + body?: { text?: string } + action?: { name?: string } + } + } + +describe("WhatsApp sendMessage — native location request", () => { + beforeEach(() => { + vi.clearAllMocks() + mockSendMessage.mockResolvedValue({ + messaging_product: "whatsapp", + messages: [{ id: "wamid.lib-1" }], + }) + mockApiFetch.mockResolvedValue( + new Response(JSON.stringify({ messages: [{ id: "wamid.raw-1" }] }), { + status: 200, + }), + ) + }) + + test("posts Cloud API location_request_message even for a phone-keyed contact", async () => { + await sendMessage({ + ctx, + data: { + contact: phoneKeyedContact, + message: { + id: "msg-1", + contentType: "text", + text: "Please share your location", + }, + quickReplies: locationRequestQuickReply, + }, + } as never) + + expect(mockSendMessage).not.toHaveBeenCalled() + expect(mockApiFetch).toHaveBeenCalledTimes(1) + const body = rawRequestBody() + expect(body.to).toBe("84123456789") + expect(body.type).toBe("interactive") + expect(body.interactive).toEqual({ + type: "location_request_message", + body: { text: "Please share your location" }, + action: { name: "send_location" }, + }) + }) + + test("does not emit location_request_message for a plain text send", async () => { + await sendMessage({ + ctx, + data: { + contact: phoneKeyedContact, + message: { id: "msg-2", contentType: "text", text: "hello" }, + }, + } as never) + + expect(mockApiFetch).not.toHaveBeenCalled() + expect(mockSendMessage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/integrations/whatsapp/src/handlers/message/outgoing-message/index.ts b/integrations/whatsapp/src/handlers/message/outgoing-message/index.ts index ea9d5c03bc..c735e06ad0 100644 --- a/integrations/whatsapp/src/handlers/message/outgoing-message/index.ts +++ b/integrations/whatsapp/src/handlers/message/outgoing-message/index.ts @@ -10,6 +10,8 @@ import { } from "@chatbotx.io/flow-config" import { contentTypes, + isWhatsappNativeLocationRequest, + type MessageButtonTemplate, type MessageHandlers, type OutgoingMessage, } from "@chatbotx.io/sdk" @@ -27,7 +29,12 @@ import { isBsuidRecipient, resolveRecipientParams, } from "../../../lib/recipient" -import type { RawWhatsappMessage, WhatsappAuthValue } from "../../../schema" +import type { + LocationRequestMessage, + RawWhatsappMessage, + WhatsappAuthValue, +} from "../../../schema" +import { clampText, messageLimits } from "../message-limits" import { generateOutgoingMessages as convertFlowStepCarousel } from "./send-carousel" import { convertFlowStepImage, @@ -38,9 +45,30 @@ import { convertFlowStepWaTemplate } from "./send-wa-template" import { convertFlowStepWhatsappFlow } from "./whatsapp-flow" import { convertFlowStepWhatsappOptionList } from "./whatsapp-option-list" +function buildLocationRequestMessage(bodyText: string): LocationRequestMessage { + return { + _type: "location_request", + type: "interactive", + interactive: { + type: "location_request_message", + body: { text: clampText(bodyText, messageLimits.bodyText) }, + action: { name: "send_location" }, + }, + } +} + function* convertMessageToWhatsappMessage( message: OutgoingMessage, -): Generator { + quickReplies?: MessageButtonTemplate[], +): Generator { + if (isWhatsappNativeLocationRequest(quickReplies)) { + const bodyText = message.text?.trim() + if (bodyText) { + yield buildLocationRequestMessage(bodyText) + } + return + } + if (message.contentType === contentTypes.enum.text) { if (message.text) { yield new Text(message.text) @@ -156,11 +184,13 @@ function* convertFlowStepToWhatsappMessage( } } -/** `whatsapp-api-js` models neither payload, so both are posted as-is. */ +/** `whatsapp-api-js` does not model these payloads, so they are posted as-is. */ const isRawWhatsappMessage = ( message: ClientMessage | RawWhatsappMessage, ): message is RawWhatsappMessage => - message._type === "template" || message._type === "interactive_carousel" + message._type === "template" || + message._type === "interactive_carousel" || + message._type === "location_request" /** * Builds the Cloud API message-body fields (everything after @@ -232,7 +262,7 @@ export const sendMessage: MessageHandlers["sendMessage"] = async (props) => { const { ctx, - data: { contact, message }, + data: { contact, message, quickReplies }, } = props const whatsappClient = getWhatsappClient(ctx.auth) const messageIds: string[] = [] @@ -240,7 +270,10 @@ export const sendMessage: MessageHandlers["sendMessage"] = const isBsuidKeyedRecipient = isBsuidRecipient(recipientParams) try { - for (const whatsappMessage of convertMessageToWhatsappMessage(message)) { + for (const whatsappMessage of convertMessageToWhatsappMessage( + message, + quickReplies, + )) { if (!whatsappMessage) { logger.error(message, "Unable to parse outgoing message") continue @@ -254,18 +287,19 @@ export const sendMessage: MessageHandlers["sendMessage"] = }, "sendMessage: dispatching outgoing message", ) - const sendResponse = isBsuidKeyedRecipient - ? await postRawMessage({ - client: whatsappClient, - phoneNumberId: ctx.auth.metadata.phoneNumber.id, - recipientParams, - message: whatsappMessage, - }) - : await whatsappClient.sendMessage( - ctx.auth.metadata.phoneNumber.id, - contact.sourceId, - whatsappMessage, - ) + const sendResponse = + isRawWhatsappMessage(whatsappMessage) || isBsuidKeyedRecipient + ? await postRawMessage({ + client: whatsappClient, + phoneNumberId: ctx.auth.metadata.phoneNumber.id, + recipientParams, + message: whatsappMessage, + }) + : await whatsappClient.sendMessage( + ctx.auth.metadata.phoneNumber.id, + contact.sourceId, + whatsappMessage, + ) const serverError = sendResponse as ServerErrorResponse diff --git a/integrations/whatsapp/src/schema.ts b/integrations/whatsapp/src/schema.ts index bb90f9df38..1a4c8d3d4d 100644 --- a/integrations/whatsapp/src/schema.ts +++ b/integrations/whatsapp/src/schema.ts @@ -225,8 +225,28 @@ export type InteractiveCarouselMessage = { } } +/** + * Cloud API `location_request_message` — Meta's native "Send location" + * button. `whatsapp-api-js` does not model this interactive type, so it is + * posted raw like templates and carousels. + * + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-location-request-messages + */ +export type LocationRequestMessage = { + _type: "location_request" + type: "interactive" + interactive: { + type: "location_request_message" + body: { text: string } + action: { name: "send_location" } + } +} + /** Messages posted raw because whatsapp-api-js does not model their payloads. */ -export type RawWhatsappMessage = InteractiveCarouselMessage | TemplateMessage +export type RawWhatsappMessage = + | InteractiveCarouselMessage + | LocationRequestMessage + | TemplateMessage export type WhatsappActions = { verifyAccessToken: Handler< diff --git a/packages/business/__tests__/reply-format-validators.test.ts b/packages/business/__tests__/reply-format-validators.test.ts index c341bc913f..1c59d09ca9 100644 --- a/packages/business/__tests__/reply-format-validators.test.ts +++ b/packages/business/__tests__/reply-format-validators.test.ts @@ -97,13 +97,49 @@ describe("replyFormatValidators", () => { ).toEqual(rejected("getUserData: invalid date")) }) - test("accepts text and location formats as text passthrough", () => { + test("accepts text format as text passthrough", () => { expect( validateReplyInput(ReplyFormat.text, makeMessage({ text: "hello" })), ).toEqual(accepted("hello")) + }) + + test("accepts a location pin as latitude and longitude text", () => { + expect( + validateReplyInput( + ReplyFormat.location, + makeMessage({ + contentType: "location", + contentAttributes: { latitude: 10.5, longitude: 106.75 }, + }), + ), + ).toEqual(accepted("10.5,106.75", "location")) + }) + + test("accepts typed lat,lng coordinates for location format", () => { + expect( + validateReplyInput( + ReplyFormat.location, + makeMessage({ text: "10.5, 106.75" }), + ), + ).toEqual(accepted("10.5,106.75", "location")) + }) + + test("rejects free text that is not a location pin or coordinate pair", () => { expect( validateReplyInput(ReplyFormat.location, makeMessage({ text: "Hanoi" })), - ).toEqual(accepted("Hanoi")) + ).toEqual(rejected("getUserData: expected a location")) + expect( + validateReplyInput( + ReplyFormat.location, + makeMessage({ text: "Received location" }), + ), + ).toEqual(rejected("getUserData: expected a location")) + }) + + test("rejects out-of-range coordinate pairs", () => { + expect( + validateReplyInput(ReplyFormat.location, makeMessage({ text: "91,0" })), + ).toEqual(rejected("getUserData: invalid location")) }) test("rejects attachment messages for text-based formats", () => { diff --git a/packages/business/src/get-user-data/reply-format-validators.ts b/packages/business/src/get-user-data/reply-format-validators.ts index a8ff64763a..2ac58d06ac 100644 --- a/packages/business/src/get-user-data/reply-format-validators.ts +++ b/packages/business/src/get-user-data/reply-format-validators.ts @@ -3,6 +3,7 @@ import { type ReplyFormat as ReplyFormatValue, } from "@chatbotx.io/flow-config" import { + asCoordinatePair, asEmail, asIs, asIsoDate, @@ -35,7 +36,16 @@ export const replyFormatValidators: Record = { fromTextWhenAttachmentAbsent(asIs), ), [ReplyFormat.link]: fromTextWhenAttachmentAbsent(asUrl), - [ReplyFormat.location]: fromTextWhenAttachmentAbsent(asIs), + // Prefer a real location pin (WhatsApp/Messenger/Zalo contentType). Typed + // "lat,lng" is the omnichannel fallback so webchat/API contacts can still + // complete RF08 without a native share-location control. + [ReplyFormat.location]: (message) => { + if (message.contentType === "location") { + return fromLocation(message) + } + + return fromTextWhenAttachmentAbsent(asCoordinatePair)(message) + }, [ReplyFormat.date]: fromTextWhenAttachmentAbsent(asIsoDate), [ReplyFormat.datetime]: fromTextWhenAttachmentAbsent(asIsoDate), [ReplyFormat.anyInput]: firstAccepted( diff --git a/packages/business/src/get-user-data/reply-input.combinators.ts b/packages/business/src/get-user-data/reply-input.combinators.ts index e519102c35..ae89191220 100644 --- a/packages/business/src/get-user-data/reply-input.combinators.ts +++ b/packages/business/src/get-user-data/reply-input.combinators.ts @@ -144,3 +144,34 @@ export const asIsoDate: TextCheck = (text) => { } export const asIs: TextCheck = (text) => accepted(text) + +const COORDINATE_PAIR_PATTERN = + /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/ + +/** + * Typed "lat,lng" fallback for channels that cannot render a native location + * control. WhatsApp Cloud API pins still go through {@link fromLocation}. + */ +export const asCoordinatePair: TextCheck = (text) => { + const match = COORDINATE_PAIR_PATTERN.exec(text) + if (!match) { + return rejected("getUserData: expected a location") + } + + const latitude = Number(match[1]) + const longitude = Number(match[2]) + if ( + !( + Number.isFinite(latitude) && + Number.isFinite(longitude) && + latitude >= -90 && + latitude <= 90 && + longitude >= -180 && + longitude <= 180 + ) + ) { + return rejected("getUserData: invalid location") + } + + return accepted(`${latitude},${longitude}`, "location") +} diff --git a/packages/sdk/__tests__/message.test.ts b/packages/sdk/__tests__/message.test.ts index 02d10e6ed2..2c72e65e61 100644 --- a/packages/sdk/__tests__/message.test.ts +++ b/packages/sdk/__tests__/message.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "vitest" import { getCanonicalReplyPayload, + isWhatsappNativeLocationRequest, type MessageButtonTemplate, + NATIVE_LOCATION_REQUEST_CHANNELS, URL_QUICK_REPLY_CAPABLE_CHANNELS, + WHATSAPP_NATIVE_LOCATION_REQUEST, } from "../src" describe("getCanonicalReplyPayload", () => { @@ -55,3 +58,40 @@ describe("URL_QUICK_REPLY_CAPABLE_CHANNELS", () => { expect(URL_QUICK_REPLY_CAPABLE_CHANNELS.has("webchat")).toBe(false) }) }) + +describe("NATIVE_LOCATION_REQUEST_CHANNELS", () => { + test("lists only WhatsApp, which can send Cloud API location_request_message", () => { + expect(NATIVE_LOCATION_REQUEST_CHANNELS.has("whatsapp")).toBe(true) + }) + + test("excludes channels that cannot render Meta's native Send location button", () => { + expect(NATIVE_LOCATION_REQUEST_CHANNELS.has("messenger")).toBe(false) + expect(NATIVE_LOCATION_REQUEST_CHANNELS.has("telegram")).toBe(false) + expect(NATIVE_LOCATION_REQUEST_CHANNELS.has("webchat")).toBe(false) + }) +}) + +describe("isWhatsappNativeLocationRequest", () => { + test("detects the reserved location-request postback", () => { + const button: MessageButtonTemplate = { + id: WHATSAPP_NATIVE_LOCATION_REQUEST, + label: "Send location", + buttonType: "postback", + postback: WHATSAPP_NATIVE_LOCATION_REQUEST, + } + + expect(isWhatsappNativeLocationRequest([button])).toBe(true) + }) + + test("returns false for ordinary postbacks and missing buttons", () => { + const button: MessageButtonTemplate = { + id: "qr-1", + label: "Yes", + buttonType: "postback", + postback: "flow-1::qr-1", + } + + expect(isWhatsappNativeLocationRequest([button])).toBe(false) + expect(isWhatsappNativeLocationRequest(undefined)).toBe(false) + }) +}) diff --git a/packages/sdk/src/lib/shared/message.ts b/packages/sdk/src/lib/shared/message.ts index 9cad6aab85..4d82bb571f 100644 --- a/packages/sdk/src/lib/shared/message.ts +++ b/packages/sdk/src/lib/shared/message.ts @@ -246,6 +246,29 @@ export const MESSENGER_NATIVE_QUICK_REPLY = { USER_PHONE_NUMBER: "messenger:native-quick-reply:user_phone_number", } as const +/** + * Reserved MessageButtonTemplate postback that asks the WhatsApp channel to + * send Cloud API `interactive.location_request_message` (Meta's native + * "Send location" button) instead of a text prompt. Only + * integrations/whatsapp's outgoing converter interprets this; every other + * channel would render it as an inert text button, so callers must gate + * emitting it to the WhatsApp channel. + * + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-location-request-messages + */ +export const WHATSAPP_NATIVE_LOCATION_REQUEST = + "whatsapp:native:location_request" as const + +/** + * Channels that can render a native "share your location" control for + * getUserData's location reply format (RF08). Callers must gate on this set + * and fall back to a plain-text prompt elsewhere — same contract as + * {@link URL_QUICK_REPLY_CAPABLE_CHANNELS}. + */ +export const NATIVE_LOCATION_REQUEST_CHANNELS: ReadonlySet = new Set([ + "whatsapp", +]) + /** * Channels whose outgoing message converter renders a `MessageButtonTemplate` * with `buttonType: "url"` as an actual link-opening button (a real @@ -287,6 +310,16 @@ export function getCanonicalReplyPayload( return button.postback ?? button.url } +export const isWhatsappNativeLocationRequest = ( + buttons: readonly MessageButtonTemplate[] | undefined, +): boolean => + Boolean( + buttons?.some( + (button) => + getCanonicalReplyPayload(button) === WHATSAPP_NATIVE_LOCATION_REQUEST, + ), + ) + export type MessageCardTemplate = { id: string title: string