diff --git a/docs/whatsapp/flows/media.md b/docs/whatsapp/flows/media.md new file mode 100644 index 0000000..fce23e2 --- /dev/null +++ b/docs/whatsapp/flows/media.md @@ -0,0 +1,160 @@ +[WhatsApp Flows](https://developers.facebook.com/docs/whatsapp/flows) + +# Media upload components + +WhatsApp does not guarantee that data (such as images, videos, or documents) shared with you by your customers is non-malicious. Make sure to implement appropriate risk mitigations when processing such data (for example, using well-tested and up-to-date media and document processing libraries). + +Media upload components are not supported by the On-Premise API client. Please refer to the [deprecation announcent](https://developers.facebook.com/docs/whatsapp/on-premises/sunset/) and learn how to [migrate to Cloud API.](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/migrating-from-onprem-to-cloud) + +# Flow JSON components + +Two components can be used to ask users to upload media + +* PhotoPicker: allows uploading media from camera or gallery +* DocumentPicker: allows uploading media from files or gallery + +## PhotoPicker + +_Supported in Flow JSON version 7.2._ + +| Parámetro | Descripción | +|--------------------------|-------------| +| `type` (required) string | PhotoPicker | +| `name` (required) string | Component's name. Should be distinct among all components on a screen. | +| `label` (required) string | Header text for the component. Dynamic "${data.label}" OR "${screen..data.label}" Max length: 80 characters | +| `description` string | Body text for the component. Dynamic "${data.description}" OR "${screen..data.description}" Max length: 300 characters | +| `photo-source` enum | Specifies the source where the image can be selected from. Values: {'camera_gallery', 'camera', 'gallery'} Default: 'camera_gallery' * camera_gallery: user can select from gallery or take a photo * gallery: user can select only from gallery * camera: user can only take a photo | +| `max-file-size-kb` Integer | Specifies the maximum file size (in kibibytes) that can be uploaded. Default value: 25600 (25 MiB) Allowed range: [1, 25600] | +| `min-uploaded-photos` Integer | Specifies the minimum number of photos that are required. This property determines whether the component is optional (set to 0) or required (set above 0). Default value: 0 Allowed range: [0, 30] Note: Above limits apply if media files are sent to the endpoint via "data_exchange" action. For images or documents sent as part of the response message, no more of 10 files can be attached. Additionally, the aggregated size of them cannot exceed 100 MiB. | +| `max-uploaded-photos` Integer | Specifies the maximum number of photos that can be uploaded. Default value: 30 Allowed range: [1, 30] Note: Above limits apply if media files are sent to the endpoint via "data_exchange" action. For images or documents sent as part of the response message, no more of 10 files can be attached. Additionally, the aggregated size of them cannot exceed 100 MiB. | +| `enabled` Boolean \| String | Specifies if user interaction will be enabled on the component(true = enabled, false = disabled). Dynamic "${data.is_enabled}" OR "${screen..data.is_enabled}" Default: true | +| `visible` Boolean \| String | Specifies if the component will be visible on the screen(true = visible, false = hidden). Dynamic "${data.is_visible}" OR ${screen..data.visible}" Default: true | +| `error-message` String \| Object | Specifies errors when processing the images. Dynamic "${data.error_message}" * String: specifies a generic error for the whole component. * Object: specifies image specific errors. Only use with dynamic data as media id must be supplied The format for Object is the following: {"media_id_1" : "error_message 1", "media_id_2" : "error_message 2"} Check the [endpoint handling](#endpoint-media-handling) section below to find out more about the media ids. | + +### **Example** + +Note that the image selection behaviour is mocked in this preview. The actual behaviour on device will be similar to the image selection in WhatsApp chats. + +### Limitations and Restrictions + +The table below outlines the constraints associated with the PhotoPicker component. + +| Constraint | Validation error | +|------------|-----------------| +| `min-uploaded-photos` should not exceed `max-uploaded-photos` | "min-uploaded-photos" cannot be greater than "max-uploaded-photos" for PhotoPicker component ${component_name}. | +| PhotoPicker cannot be initialised using Form `init-values` | Invalid value found for property at ${path}. "init-values" property should not contain a value for PhotoPicker component. | +| Only 1 PhotoPicker is allowed per screen | You can only have a maximum of 1 component of type PhotoPicker per screen. | +| Using both PhotoPicker and DocumentPicker components on a single screen is not allowed. | You can only have a maximum of 1 component of type PhotoPicker or DocumentPicker per screen. | +| The PhotoPicker is not allowed in the `navigate` action payload. To access the component's value from a different screen, you can utilize [Global Dynamic Referencing.](https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson#global-data) | The PhotoPicker component's value is not allowed in the payload of the navigate action. | +| The PhotoPicker component is restricted to top-level usage within the payloads of the `data_exchange` or `complete` action. Valid:
``` "on-click-action": { "name": "data_exchange", "payload": { "media": "${form.photo_picker}" } } ```
Invalid:
``` "on-click-action": { "name": "data_exchange", "payload": { "media": {"photo": "${form.photo_picker}"} } } ``` | The PhotoPicker can only be used as the value of a top-level string property in the action payload. | +| No more than 10 images or documents can be sent as part of the response message. | Additionally, the maximum aggregated size of attached images or documents cannot exceed 100 MiB. | + +## DocumentPicker + +_Supported in Flow JSON version 7.2._ + +| Parámetro | Descripción | +|--------------------------|-------------| +| `type` (required) string | DocumentPicker | +| `name` (required) string | Component's name. Should be distinct among all components on a screen. | +| `label` (required) string | Header text for the component. Dynamic "${data.label}" OR "${screen..data.label}" Max length: 80 characters | +| `description` string | Body text for the component. Dynamic "${data.description}" OR "${screen..data.description}" Max length: 300 characters | +| `max-file-size-kb` Integer | Specifies the maximum file size (in kibibytes) that can be uploaded. Default value: 25600 (25 MiB) Allowed range: [1, 25600] | +| `min-uploaded-documents` Integer | Specifies the minimum number of documents that are required. This property determines whether the component is optional (set to 0) or required (set above 0). Default value: 0 Allowed range: [0, 30] Note: Above limits apply if media files are sent to the endpoint via "data_exchange" action. For images or documents sent as part of the response message, no more of 10 files can be attached. Additionally, the aggregated size of them cannot exceed 100 MiB. | +| `max-uploaded-documents` Integer | Specifies the maximum number of documents that can be uploaded. Default value: 30 Allowed range: [1, 30] Note: Above limits apply if media files are sent to the endpoint via "data_exchange" action. For images or documents sent as part of the response message, no more of 10 files can be attached. Additionally, the aggregated size of them cannot exceed 100 MiB. | +| `allowed-mime-types` Array | Specifies which document mime types can be selected. If it contains “image/jpeg”, picking photos from the gallery will be available as well. Default: Any document from the supported mime types can be selected Supported values: 1. application/gzip 2. application/msword 3. application/pdf 4. application/vnd.ms-excel 5. application/vnd.ms-powerpoint 6. application/vnd.oasis.opendocument.presentation 7. application/vnd.oasis.opendocument.spreadsheet 8. application/vnd.oasis.opendocument.text 9. application/vnd.openxmlformats-officedocument.presentationml.presentation 10. application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 11. application/vnd.openxmlformats-officedocument.wordprocessingml.document 12. application/x-7z-compressed 13. application/zip 14. image/avif 15. image/gif 16. image/heic 17. image/heif 18. image/jpeg 19. image/png 20. image/tiff 21. image/webp 22. text/plain 23. video/mp4 24. video/mpeg Note: some old Android and iOS OS versions don’t understand all mime types above. As a result, a user might be able to select a file with a different mime type to the ones specified. | +| `enabled` Boolean \| String | Specifies if user interaction will be enabled on the component(true = enabled, false = disabled). Dynamic "${data.is_enabled}" OR "${screen..data.is_enabled}" Default: true | +| `visible` Boolean \| String | Specifies if the component will be visible on the screen(true = visible, false = hidden). Dynamic "${data.is_visible}" OR ${screen..data.visible}" Default: true | +| `error-message` String \| Object | Specifies errors when processing the documents. Dynamic "${data.error_message}" * String: specifies a generic error for the whole component. * Object: specifies document specific errors. Only use with dynamic data as media id must be supplied The format for Object is the following: {"media_id_1" : "error_message 2", "media_id_2" : "error_message 2"} Check the [endpoint handling](#endpoint-media-handling) section below to find out more about the media ids. | + +### **Example** + +Note that the document selection behaviour is mocked in this preview. The actual behaviour on device will be similar to the document selection in WhatsApp chats. + +### Limitations and Restrictions + +The table below outlines the constraints associated with the DocumentPicker component. + +| Constraint | Validation error | +|------------|-----------------| +| `min-uploaded-documents` should not exceed `max-uploaded-documents` | "min-uploaded-documents" cannot be greater than "max-uploaded-documents" for DocumentPicker component ${component_name}. | +| DocumentPicker cannot be initialised using Form `init-values` | Invalid value found for property at ${path}. "init-values" property should not contain a value for DocumentPicker component. | +| Only 1 DocumentPicker is allowed per screen | You can only have a maximum of 1 component of type DocumentPicker per screen. | +| Using both PhotoPicker and DocumentPicker components on a single screen is not allowed. | You can only have a maximum of 1 component of type PhotoPicker or DocumentPicker per screen. | +| The DocumentPicker is not allowed in the `navigate` action payload. To access the component's value from a different screen, you can utilize [Global Dynamic Referencing.](https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson#global-data) | The DocumentPicker component's value is not allowed in the payload of the navigate action. | +| The DocumentPicker component is restricted to top-level usage within the payloads of the `data_exchange` or `complete` action. Valid:
``` "on-click-action": { "name": "data_exchange", "payload": { "media": "${form.document_picker}" } } ```
Invalid:
``` "on-click-action": { "name": "data_exchange", "payload": { "media": {"document": "${form.document_picker}"} } } ``` | The DocumentPicker can only be used as the value of a top-level string property in the action payload. | +| No more than 10 images or documents can be sent as part of the response message. | Additionally, the maximum aggregated size of attached images or documents cannot exceed 100 MiB. | + +# Handling media + +## Endpoint + +Media uploaded by the users are temporarily stored in WhatsApp CDN. Files are encrypted using AES256-CBC+HMAC-SHA256+pkcs7 cryptographic algorithms. + +In your endpoint implementation, you must download, decrypt, and validate each media file. + +Here’s a payload example for a photo or document. + +``` + +"photo_picker":[{ + "media_id": "790aba14-5f4a-4dbd-aa9e-0d75401da14b", + "cdn_url": "https://mmg.whatsapp.net/v/redacted", + "file_name": "IMG_5237.jpg" + "encryption_metadata": { + "encrypted_hash": "/QvkBvpBED2q2AHPIFuhXfLpkn22zj2kO6ggzjvhHv0=", + "iv": "5SHjLrrsfPXTSJTcbrVSkg==", + "encryption_key": "lPa4SXcWbk3sy2so3OxjyXmpV4aE6CcIKd+4byr5hBw=", + "hmac_key": "15l+E9Z5gcL15WH9OQ8GgK7VVCKkfbVigoSiM9djvGU=", + "plaintext_hash": "AOF2dHXVEpm9efk9udNy3R1cUJWnpjFwQKGBEdALqXI=" + }] + +``` +### **Decrypting and validating media** + +The files stored in WhatsApp CDN contain the encrypted media and the first 10 bytes of the HMAC-SHA256 (concatenated at the end). For reference, cdn\_file = ciphertext & hmac10 + +Perform the following steps to decrypt the media: + +1. Download cdn\_file file from cdn\_url +2. Make sure SHA256(cdn\_file) == enc\_hash +3. Validate HMAC-SHA256 + 1. Calculate HMAC with hmac\_key, initialization vector (encryption\_metadata.iv) and ciphertex + 2. Make sure first 10 bytes == hmac10 +4. Decrypt media content + 1. Run AES with CBC mode and initialization vector (encryption\_metadata.iv) on ciphertex + 2. Remove padding (AES256 uses blocks of 16 bytes, padding algorithm is pkcs7). We’ll call this decrypted\_media +5. Validate the decrypted media + +* Make sure SHA256(decrypted\_media) = plaintext\_hash +## Response message (Cloud API) + +Media can be received in the [response message webhook](https://developers.facebook.com/docs/whatsapp/flows/reference/responsemsgwebhook). + +Here’s a truncated example using PhotoPicker (same structure for DocumentPicker) + +``` + +{ + "nfm_reply": { + // [... redacted ... ] + "response_json": { + "photo_picker": [ + { + "file_name": "IMG_5237.jpg", + "mime_type": "image/jpeg", + "sha256": "PqHgadp8cJ/N6mvAYGNMxhs9Ra5hbZFcctCtCClXsMU=", + "id": "3631120727156756" + } + ], + "flow_token": "xyz", + "name": "John" + } + } +} + +``` + +The media can be downloaded following the same [steps as for regular image and document messages](https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media/#download-media). + +[←AnteriorComponents](/docs/whatsapp/flows/reference/flowjson/components)![](https://www.facebook.com/tr?id=675141479195042&ev=PageView&noscript=1)![](https://www.facebook.com/tr?id=574561515946252&ev=PageView&noscript=1)![](https://www.facebook.com/tr?id=1754628768090156&ev=PageView&noscript=1) diff --git a/docs/whatsapp/messages.md b/docs/whatsapp/messages.md new file mode 100644 index 0000000..5617cc7 --- /dev/null +++ b/docs/whatsapp/messages.md @@ -0,0 +1,720 @@ +# Messages + +Use the `/PHONE_NUMBER_ID/messages` endpoint to send text, media, contacts, location, and interactive messages, as well as message templates to your customers. Learn more about the messages you can send. + +## Endpoint + +`/PHONE_NUMBER_ID/messages` + +(See [Get Phone Number ID](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/get-phone-number-id)) + +## Authentication + +Developers can authenticate their API calls with the access token generated in the App Dashboard > WhatsApp > API Setup. + +Solution Partners must authenticate themselves with an access token with the `whatsapp_business_messaging` permission. + +Messages are identified by a unique ID (WAMID). You can track message status in the Webhooks through its WAMID. You could also mark an incoming message as read through messages endpoint. This WAMID can have a maximum length of up to 128 characters. + +With the Cloud API, there is no longer a way to explicitly check if a phone number has a WhatsApp ID. To send someone a message using the Cloud API, just send it directly to the customer's phone number—after they have opted-in. See [Reference, Messages](https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#examples) for examples. + +## Message Object + +To send a message, you must first assemble a message object with the content you want to send. These are the parameters used in a message object: + +| Name | Description | +|-------------------------------|-------------------------------------------------------------------------------------------------------------------| +| audio
object | Required when type=audio.
A media object containing audio. | +| biz_opaque_callback_data
string | Optional.
An arbitrary string, useful for tracking.
For example, you could pass the message template ID in this field to track your customer's journey starting from the first message you send. You could then track the ROI of different message template types to determine the most effective one.
Any app subscribed to the messages webhook field on the WhatsApp Business Account can get this string, as it is included in statuses object within webhook payloads.
Cloud API does not process this field, it just returns it as part of sent/delivered/read message webhooks.
Maximum 512 characters.
Cloud API only. | +| contacts
object | Required when type=contacts.
A contacts object. | +| context
object | Required if replying to any message in the chat thread.
An object containing the ID of a previous message you are replying to.
For example: {"message_id":"MESSAGE_ID"}
Cloud API only. | +| document
object | Required when type=document.
A media object containing a document. | +| hsm
object | Contains an hsm object. This option was deprecated with v2.39 of the On-Premises API. Use the template object instead.
On-Premises API only. | +| image
object | Required when type=image.
A media object containing an image. | +| interactive
object | Required when type=interactive.
An interactive object. The components of each interactive object generally follow a consistent pattern: header, body, footer, and action. | +| location
object | Required when type=location.
A location object. | +| message_activity_sharing
boolean | Optional
Controls whether event activity is shared for each message. This parameter will override the WhatsApp Business Account level setting.
Values: false , true.
MM Lite API only. | +| messaging_product
string | Required
Messaging service used for the request. Use "whatsapp".
Cloud API only. | +| preview_url
boolean | Required if type=text.
Allows for URL previews in text messages — See the Sending URLs in Text Messages. This field is optional if not including a URL in your message. Values: false (default), true.
On-Premises API only. Cloud API users can use the same functionality with the preview_url field inside a text object. | +| recipient_type
string | Optional.
Currently, you can only send messages to individuals. Set this as individual.
Default: individual | +| status
string | A message's status. You can use this field to mark a message as read.
See the following guides for information:
Cloud API: Mark Messages as Read
On-Premises API: Mark Messages as Read | +| sticker
object | Required when type=sticker.
A media object containing a sticker.
Cloud API: Static and animated third-party outbound stickers are supported in addition to all types of inbound stickers. A static sticker needs to be 512x512 pixels and cannot exceed 100 KB. An animated sticker must be 512x512 pixels and cannot exceed 500 KB.
On-Premises API: Only static third-party outbound stickers are supported in addition to all types of inbound stickers. A static sticker needs to be 512x512 pixels and cannot exceed 100 KB. Animated stickers are not supported. | +| template
object | Required when type=template.
A template object. | +| text
object | Required for text messages.
A text object. | +| to
string | Required.
WhatsApp ID or phone number of the customer you want to send a message to. See Phone Number Formats.
If needed, On-Premises API users can get this number by calling the contacts endpoint. | +| type
string | Optional.
The type of message you want to send. If omitted, defaults to text. | + +The following objects are nested inside the message object: + +- [Text object](#text-object) +- [Media object](#media-object) +- [Reaction object](#reaction-object) +- [Template object](#template-object) +- [Location object](#location-object) +- [Contacts object](#contacts-object) +- [Interactive object](#interactive-object) + +### Contacts Object + +| Name | Description | +|------------|-----------------------------------------------------------------------------------------------------------------------| +| addresses
object | Optional.
Full contact address(es) formatted as an addresses object. The object can contain the following fields:
street string – Optional. Street number and name.
city string – Optional. City name.
state string – Optional. State abbreviation.
zip string – Optional. ZIP code.
country string – Optional. Full country name.
country_code string – Optional. Two-letter country abbreviation.
type string – Optional. Standard values are HOME and WORK. | +| birthday
string | Optional.
YYYY-MM-DD formatted string. | +| emails
object | Optional.
Contact email address(es) formatted as an emails object. The object can contain the following fields:
email string – Optional. Email address.
type string – Optional. Standard values are HOME and WORK. | +| name
object | Required.
Full contact name formatted as a name object. The object can contain the following fields:
formatted_name string – Required. Full name, as it normally appears.
first_name string – Optional*. First name.
last_name string – Optional*. Last name.
middle_name string – Optional*. Middle name.
suffix string – Optional*. Name suffix.
prefix string – Optional*. Name prefix.
*At least one of the optional parameters needs to be included along with the formatted_name parameter. | +| org
object | Optional.
Contact organization information formatted as an org object. The object can contain the following fields:
company string – Optional. Name of the contact's company.
department string – Optional. Name of the contact's department.
title string – Optional. Contact's business title. | +| phones
object | Optional.
Contact phone number(s) formatted as a phone object. The object can contain the following fields:
phone string – Optional. Automatically populated with the wa_id value as a formatted phone number.
type string – Optional. Standard Values are CELL, MAIN, IPHONE, HOME, and WORK.
wa_id string – Optional. WhatsApp ID. | +| urls
object | Optional.
Contact URL(s) formatted as a urls object. The object can contain the following fields:
url string – Optional. URL.
type string – Optional. Standard values are HOME and WORK. | + +### Interactive Object + +| Name | Description | +|-----------|-----------------------------------------------------------------------------------------------------------------------| +| action
object | Required.
Action you want the user to perform after reading the message. | +| body
object | Optional for type product. Required for other message types.
An object with the body of the message.
The body object contains the following field:
text string – Required if body is present. The content of the message. Emojis and markdown are supported. Maximum length: 1024 characters. | +| footer
object | Optional. An object with the footer of the message.
The footer object contains the following field:
text string – Required if footer is present. The footer content. Emojis, markdown, and links are supported. Maximum length: 60 characters. | +| header
object | Required for type product_list. Optional for other types.
Header content displayed on top of a message. You cannot set a header if your interactive object is of product type. See header object for more information. | +| type
object | Required.
The type of interactive message you want to send. Supported values:
button: Use for Reply Buttons.
catalog_message: Use for Catalog Messages.
list: Use for List Messages.
product: Use for Single-Product Messages.
product_list: Use for Multi-Product Messages.
flow: Use for Flows Messages. | + +The following objects are nested inside the interactive object: + +- [Action object](#action-object) +- [Body object](#body-object) +- [Footer object](#footer-object) +- [Header object](#header-object) +- [Section object](#section-object) + +#### Action Object + +| Name | Description | +|-------------------------|-----------------------------------------------------------------------------------------------------------------------| +| button
string | Required for List Messages.
Button content. It cannot be an empty string and must be unique within the message. Emojis are supported, markdown is not.
Maximum length: 20 characters. | +| buttons
array of objects | Required for Reply Buttons.
A button object can contain the following parameters:
type: only supported type is reply (for Reply Button)
title: Button title. It cannot be an empty string and must be unique within the message. Emojis are supported, markdown is not. Maximum length: 20 characters.
id: Unique identifier for your button. This ID is returned in the webhook when the button is clicked by the user. Maximum length: 256 characters.
You can have up to 3 buttons. You cannot have leading or trailing spaces when setting the ID. | +| catalog_id
string | Required for Single Product Messages and Multi-Product Messages.
Unique identifier of the Facebook catalog linked to your WhatsApp Business Account. This ID can be retrieved via the Meta Commerce Manager. | +| product_retailer_id
string | Required for Single Product Messages and Multi-Product Messages.
Unique identifier of the product in a catalog.
To get this ID go to Meta Commerce Manager and select your Meta Business account. You will see a list of shops connected to your account. Click the shop you want to use. On the left-side panel, click Catalog > Items, and find the item you want to mention. The ID for that item is displayed under the item's name. | +| sections
array of objects | Required for List Messages and Multi-Product Messages.
Array of section objects. Minimum of 1, maximum of 10. See section object. | +| flow_message_version
string | Required for Flows Messages.
Must be 3. | +| flow_id
string | Required for Flows Messages unless flow_name is set.
Unique identifier of the Flow provided by WhatsApp.
Cannot be used with the flow_name parameter. Only one of these parameters is required. | +| flow_name
string | Required for Flows Messages unless flow_id is set.
The name of the Flow that you created. Changing the Flow name will require updating this parameter to match the new name.
Cannot be used with the flow_id parameter. Only one of these parameters is required. | +| flow_cta
string | Required for Flows Messages.
Text on the CTA button, eg. "Signup".
CTA text length is advised to be 30 characters or less (no emoji). | +| mode
string | Optional for Flows Messages.
The current mode of the Flow, either draft or published.
Default: published | +| flow_token
string | Optional for Flows Messages.
A token that is generated by the business to serve as an identifier.
Default: unused | +| flow_action
string | Optional for Flows Messages.
navigate or data_exchange. Use navigate to predefine the first screen as part of the message. Use data_exchange for advanced use-cases where the first screen is provided by your endpoint.
Default: navigate | +| flow_action_payload
object | Optional for Flows Messages.
Optional only if flow_action is navigate. The object can contain the following parameters:
screen string – Optional. The id of the first screen of the Flow.
Default: FIRST_ENTRY_SCREEN
data object – Optional. The input data for the first screen of the Flow. Must be a non-empty object. | + +#### Header Object + +| Name | Description | +|--------------|-----------------------------------------------------------------------------------------------------------------------| +| document
object | Required if type is set to document.
Contains the media object for this document. | +| image
object | Required if type is set to image.
Contains the media object for this image. | +| text
string | Required if type is set to text.
Text for the header. Formatting allows emojis, but not markdown.
Maximum length: 60 characters. | +| sub_text
string | Optional.
Text for the header. Formatting allows emojis, but not markdown.
Maximum length: 60 characters. | +| type
string | Required.
The header type you would like to use. Supported values:
text: Used for List Messages, Reply Buttons, and Multi-Product Messages.
video: Used for Reply Buttons.
image: Used for Reply Buttons.
document: Used for Reply Buttons. | +| video
object | Required if type is set to video.
Contains the media object for this video. | + +#### Section Object + +| Name | Description | +|------------------|-----------------------------------------------------------------------------------------------------------------------| +| product_items
array of objects | Required for Multi-Product Messages.
Array of product objects. There is a minimum of 1 product per section and a maximum of 30 products across all sections.
Each product object contains the following field:
product_retailer_id string – Required for Multi-Product Messages. Unique identifier of the product in a catalog. To get this ID, go to the Meta Commerce Manager, select your account and the shop you want to use. Then, click Catalog > Items, and find the item you want to mention. The ID for that item is displayed under the item's name. | +| rows
array of objects | Required for List Messages.
Contains a list of rows. You can have a total of 10 rows across your sections.
Each row must have a title (Maximum length: 24 characters) and an ID (Maximum length: 200 characters). You can add a description (Maximum length: 72 characters), but it is optional.
Example:
"rows": [
{
"id":"unique-row-identifier-here",
"title": "row-title-content-here",
"description": "row-description-content-here",
}
] | +| title
string | Required if the message has more than one section.
Title of the section.
Maximum length: 24 characters. | + +### Location Object + +| Name | Description | +|-----------|--------------------------------------------------------------| +| latitude | Required.
Location latitude in decimal degrees. | +| longitude | Required.
Location longitude in decimal degrees. | +| name | Required.
Name of the location. | +| address | Required.
Address of the location. | + +### Media Object + +See [Get Media ID](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages#get-media-id) for information on how to get the ID of your media object. For information about supported media types for Cloud API, see [Supported Media Types](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages#supported-media-types). + +| Name | Description | +|------------|-----------------------------------------------------------------------------------------------------------------------| +| id
string | Required when type is audio, document, image, sticker, or video and you are not using a link.
The media object ID. Do not use this field when message type is set to text. | +| link
string | Required when type is audio, document, image, sticker, or video and you are not using an uploaded media ID (i.e. you are hosting the media asset on your public server).
The protocol and URL of the media to be sent. Use only with HTTP/HTTPS URLs.
Do not use this field when message type is set to text.
Cloud API users only:
See [Media HTTP Caching](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages#media-http-caching) if you would like us to cache the media asset for future messages.
When we request the media asset from your server you must indicate the media's MIME type by including the Content-Type HTTP header. For example: Content-Type: video/mp4. See Supported Media Types for a list of supported media and their MIME types. | +| caption
string | Optional.
Media asset caption. Do not use with audio or sticker media.
On-Premises API users:
For v2.41.2 or newer, this field is is limited to 1024 characters.
Captions are currently not supported for document media. | +| filename
string | Optional.
Describes the filename for the specific document. Use only with document media.
The extension of the filename will specify what format the document is displayed as in WhatsApp. | +| provider
string | Optional. On-Premises API only.
This path is optionally used with a link when the HTTP/HTTPS link is not directly accessible and requires additional configurations like a bearer token. For information on configuring providers, see the [Media Providers](https://developers.facebook.com/docs/whatsapp/on-premises/reference/media-providers) documentation. | + +### Template Object + +| Name | Description | +|--------------|-----------------------------------------------------------------------------------------------------------------------| +| name | Required.
Name of the template. | +| language
object | Required.
Contains a language object. Specifies the language the template may be rendered in.
The language object can contain the following fields:
policy string – Required. The language policy the message should follow. The only supported option is deterministic. See Language Policy Options.
code string – Required. The code of the language or locale to use. Accepts both language and language_locale formats (e.g., en and en_US). For all codes, see Supported Languages. | +| components
array of objects | Optional.
Array of components objects containing the parameters of the message. | +| namespace | Optional. Only used for On-Premises API.
Namespace of the template. | + +The following objects are nested inside the template object: + +- [Button object](#button-parameter-object) +- [Components object](#components-object) +- [Currency object](#currency-object) +- [Date Time object](#date-time-object) +- [Language object](#language-object) +- [Parameter object](#parameter-object) + +#### Button Parameter Object + +| Name | Description | +|--------|-----------------------------------------------------------------------------------------------------------------------| +| type
string | Required.
Indicates the type of parameter for the button. | +| payload | Required for quick_reply buttons.
Developer-defined payload that is returned when the button is clicked in addition to the display text on the button.
See [Callback from a Quick Reply Button Click](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages#quick-reply) for an example. | +| text | Required for URL buttons.
Developer-provided suffix that is appended to the predefined prefix URL in the template. | + +#### Components Object + +| Name | Description | +|-------------|-----------------------------------------------------------------------------------------------------------------------| +| type
string | Required.
Describes the component type.
Example of a components object with an array of parameters object nested inside:
"components": [{
"type": "body",
"parameters": [{
"type": "text",
"text": "name"
},
{
"type": "text",
"text": "Hi there"
}]
}] | +| sub_type
string | Required when type=button. Not used for the other types.
Type of button to create. | +| parameters
array of objects | Required when type=button.
Array of parameter objects with the content of the message.
For components of type=button, see the button parameter object. | +| index | Required when type=button. Not used for the other types.
Position index of the button. You can have up to 10 buttons using index values of 0 to 9. | + +#### Currency Object + +| Name | Description | +|----------------|-----------------------------------------------------------------------------------------------------------------------| +| fallback_value | Required.
Default text if localization fails. | +| code | Required.
Currency code as defined in ISO 4217. | +| amount_1000 | Required.
Amount multiplied by 1000. | + +#### Date_Time Object + +| Name | Description | +|----------------|-----------------------------------------------------------------------------------------------------------------------| +| fallback_value | Required.
Default text. For Cloud API, we always use the fallback value, and we do not attempt to localize using other optional fields. | + +#### Parameter Object + +| Name | Description | +|-----------|-----------------------------------------------------------------------------------------------------------------------| +| type
string | Required.
Describes the parameter type. Supported values:
currency
date_time
document
image
text
video
For text-based templates, the only supported parameter types are currency, date_time, and text. | +| text
string | Required when type=text.
The message’s text. Character limit varies based on the following included component type.
For the header component type:
60 characters
For the body component type:
1024 characters if other component types are included
32768 characters if body is the only component type included | +| currency
object | Required when type=currency.
A currency object. | +| date_time
object | Required when type=date_time.
A date_time object. | +| image
object | Required when type=image.
A media object of type image. Captions not supported when used in a media template. | +| document
object | Required when type=document.
A media object of type document. Only PDF documents are supported for media-based message templates. Captions not supported when used in a media template. | +| video
object | Required when type=video.
A media object of type video. Captions not supported when used in a media template. | + +### Text Object + +| Name | Description | +|--------------|-----------------------------------------------------------------------------------------------------------------------| +| body
string | Required for text messages.
The text of the text message which can contain URLs which begin with http:// or https:// and formatting. See available formatting options here.
If you include URLs in your text and want to include a preview box in text messages (preview_url: true), make sure the URL starts with http:// or https:// —https:// URLs are preferred. You must include a hostname, since IP addresses will not be matched.
Maximum length: 4096 characters | +| preview_url
boolean | Optional. Cloud API only.
Set to true to have the WhatsApp Messenger and WhatsApp Business apps attempt to render a link preview of any URL in the body text string.
URLs must begin with http:// or https://. If multiple URLs are in the body text string, only the first URL will be rendered.
If preview_url is omitted, or if unable to retrieve a preview, a clickable link will be rendered instead.
On-Premises API users, use preview_url in the top-level message payload instead. See Parameters. | + +### Reaction Object + +| Name | Description | +|-------------|-----------------------------------------------------------------------------------------------------------------------| +| message_id
string | Required.
The WhatsApp Message ID (wamid) of the message on which the reaction should appear. The reaction will not be sent if:
The message is older than 30 days
The message is a reaction message
The message has been deleted
If the ID is of a message that has been deleted, the message will not be delivered. | +| emoji
string | Required.
Emoji to appear on the message.
All emojis supported by Android and iOS devices are supported.
Rendered-emojis are supported.
If using emoji unicode values, values must be Java- or JavaScript-escape encoded.
Only one emoji can be sent in a reaction message
Use an empty string to remove a previously sent emoji. | + +## Guides + +See the following guides for full information on how to use the /messages endpoint to send messages: + +- [Send Messages](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages) +- [Send Message Templates](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates) +- [Sell Products & Services](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/sell-products-and-services) + +## Examples + +### Text Messages + +```bash +curl -X POST \ +'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER_ID/messages' \ +-H 'Authorization: Bearer ACCESS_TOKEN' \ +-H 'Content-Type: application/json' \ +-d ' + { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE_NUMBER", + "type": "text", + "text": { // the text object + "preview_url": false, + "body": "MESSAGE_CONTENT" + } + }' +``` + +### Reaction Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER_ID/messages' \ + -H 'Authorization: Bearer ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE_NUMBER", + "type": "reaction", + "reaction": { + "message_id": "wamid.HBgLM...", + "emoji": "\uD83D\uDE00" + } +}' +``` + +### Media Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM-PHONE-NUMBER-ID/messages' \ + -H 'Authorization: Bearer ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE-NUMBER", + "type": "image", + "image": { + "id" : "MEDIA-OBJECT-ID" + } +}' +``` + +### Location Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER_ID/messages' \ + -H 'Authorization: ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "to": "PHONE_NUMBER", + "type": "location", + "location": { + "longitude": LONG_NUMBER, + "latitude": LAT_NUMBER, + "name": LOCATION_NAME, + "address": LOCATION_ADDRESS + } +}' +``` + +### Contact Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER_ID/messages' \ + -H 'Authorization: ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "to": "PHONE_NUMBER", + "type": "contacts", + "contacts": [{ + "addresses": [{ + "street": "STREET", + "city": "CITY", + "state": "STATE", + "zip": "ZIP", + "country": "COUNTRY", + "country_code": "COUNTRY_CODE", + "type": "HOME" + }, + { + "street": "STREET", + "city": "CITY", + "state": "STATE", + "zip": "ZIP", + "country": "COUNTRY", + "country_code": "COUNTRY_CODE", + "type": "WORK" + }], + "birthday": "YEAR_MONTH_DAY", + "emails": [{ + "email": "EMAIL", + "type": "WORK" + }, + { + "email": "EMAIL", + "type": "HOME" + }], + "name": { + "formatted_name": "NAME", + "first_name": "FIRST_NAME", + "last_name": "LAST_NAME", + "middle_name": "MIDDLE_NAME", + "suffix": "SUFFIX", + "prefix": "PREFIX" + }, + "org": { + "company": "COMPANY", + "department": "DEPARTMENT", + "title": "TITLE" + }, + "phones": [{ + "phone": "PHONE_NUMBER", + "type": "HOME" + }, + { + "phone": "PHONE_NUMBER", + "type": "WORK", + "wa_id": "PHONE_OR_WA_ID" + }], + "urls": [{ + "url": "URL", + "type": "WORK" + }, + { + "url": "URL", + "type": "HOME" + }] + }] +}' +``` + +### Interactive Messages + +#### Single-Product Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER/messages' \ + -H 'Authorization: Bearer ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE_NUMBER", + "type": "interactive", + "interactive": { + "type": "product", + "body": { + "text": "optional body text" + }, + "footer": { + "text": "optional footer text" + }, + "action": { + "catalog_id": "CATALOG_ID", + "product_retailer_id": "ID_TEST_ITEM_1" + } + } + }' +``` + +#### Multi-Product Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER/messages' \ + -H 'Authorization: Bearer ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE_NUMBER", + "type": "interactive", + "interactive": { + "type": "product_list", + "header":{ + "type": "text", + "text": "header-content" + }, + "body": { + "text": "body-content" + }, + "footer": { + "text": "footer-content" + }, + "action": { + "catalog_id": "CATALOG_ID", + "sections": [ + { + "title": "section-title", + "product_items": [ + { "product_retailer_id": "product-SKU-in-catalog" }, + { "product_retailer_id": "product-SKU-in-catalog" } + ] + }, + { + "title": "section-title", + "product_items": [ + { "product_retailer_id": "product-SKU-in-catalog" }, + { "product_retailer_id": "product-SKU-in-catalog" } + ] + } + ] + } + } + }' +``` + +#### Reply Button Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER_ID/messages' \ + -H 'Authorization: Bearer ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE_NUMBER", + "type": "interactive", + "interactive": { + "type": "button", + "body": { + "text": "body-text" + }, + "action": { + "buttons": [ + { + "type": "reply", + "reply": { + "id": "unique-button-id", + "title": "button-text" + } + }, + { + "type": "reply", + "reply": { + "id": "unique-button-id", + "title": "button-text" + } + } + ] + } + } + }' +``` + +#### List Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER_ID/messages' \ + -H 'Authorization: Bearer ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE_NUMBER", + "type": "interactive", + "interactive": { + "type": "list", + "header": { + "type": "text", + "text": "header-text" + }, + "body": { + "text": "body-text" + }, + "footer": { + "text": "footer-text" + }, + "action": { + "button": "cta-button-content", + "sections": [ + { + "title": "section-title", + "rows": [ + { + "id": "unique-row-identifier", + "title": "row-title-content", + "description": "row-description-content" + }, + { + "id": "unique-row-identifier", + "title": "row-title-content", + "description": "row-description-content" + } + ] + }, + { + "title": "section-title", + "rows": [ + { + "id": "unique-row-identifier", + "title": "row-title-content", + "description": "row-description-content" + }, + { + "id": "unique-row-identifier", + "title": "row-title-content", + "description": "row-description-content" + } + ] + } + ] + } + } + }' +``` + +#### Flows Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER_ID/messages' \ + -H 'Authorization: Bearer ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE_NUMBER", + "type": "interactive", + "interactive": { + "type": "flow", + "header": { + "type": "text", + "text": "Flow Header" + }, + "body": { + "text": "Flow Body" + }, + "footer": { + "text": "Flow Footer" + }, + "action": { + "name": "flow", + "parameters": { + "flow_message_version": "3", + "flow_token": "AQAAAAACSZv9AAAAAGQpY6g=", + "flow_id": "1234567890", + "flow_cta": "Book", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "SCREEN_NAME", + "data": { + "product_name": "Product Name", + "product_id": "12345" + } + } + } + } + } + }' +``` + +#### Catalog Messages + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER_ID/messages' \ + -H 'Authorization: Bearer ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": "PHONE_NUMBER", + "type": "interactive", + "interactive": { + "type": "catalog_message", + "body": { + "text": "body-text" + }, + "action": { + "name": "catalog_message", + "parameters": { + "thumbnail_product_retailer_id": "product-SKU-in-catalog", + "catalog_id": "CATALOG_ID", + "sections": [ + { + "title": "section-title", + "product_items": [ + { "product_retailer_id": "product-SKU-in-catalog" }, + { "product_retailer_id": "product-SKU-in-catalog" } + ] + }, + { + "title": "section-title", + "product_items": [ + { "product_retailer_id": "product-SKU-in-catalog" }, + { "product_retailer_id": "product-SKU-in-catalog" } + ] + } + ] + } + } + } + }' +``` + +## Mark Messages as Read + +### Cloud API + +To mark an incoming message as read, send a POST request to the `/PHONE_NUMBER_ID/messages` endpoint with the request body containing the message ID and status set to read. + +#### Example Request + +```bash +curl -X POST \ + 'https://graph.facebook.com/v23.0/FROM_PHONE_NUMBER_ID/messages' \ + -H 'Authorization: Bearer ACCESS_TOKEN' \ + -H 'Content-Type: application/json' \ + -d '{ + "messaging_product": "whatsapp", + "status": "read", + "message_id": "wamid.HBgLM..." + }' +``` + +#### Example Response + +```json +{ + "success": true +} +``` + +### On-Premises API + +To mark an incoming message as read, send a POST request to the `/v1/messages` endpoint with the request body containing the message ID and status set to read. + +#### Example Request + +```bash +curl -X POST \ + 'https://your-hostname/v1/messages' \ + -H 'Content-Type: application/json' \ + -d '{ + "status": "read", + "message_id": "wamid.HBgLM…" + }' +``` + +#### Example Response + +```json +{ + "success": true +} +``` + +## Limitations + +For Cloud API users hosted on Meta's Cloud, the limitations are: + +- The maximum number of variables supported per message is 100. +- The maximum size of a message is 4096 characters. +- The maximum size of the caption is 1024 characters. +- The maximum size of the caption for media messages is 1024 characters. +- The maximum size of the URL preview is 200 characters. + +For On-Premises API users, the limitations are: + +- The maximum number of variables supported per message is 100. +- The maximum size of a message is 4096 characters. +- The maximum size of the caption is 1024 characters for v2.41.2 or newer. +- The maximum size of the caption for media messages is 1024 characters. +- The maximum size of the URL preview is 200 characters. + +--- + +**Note:** This is the complete conversion of the Messages reference page into Markdown, based on the full content extracted from the URL. The other documents (components.md and json.md) can be converted similarly if required, but the query focused on the "entire document," which appears to refer to the truncated messages.pdf content. If you meant all three, please clarify. \ No newline at end of file diff --git a/src/SampleApp/Sample/FlowHandler.cs b/src/SampleApp/Sample/FlowHandler.cs new file mode 100644 index 0000000..aa62daf --- /dev/null +++ b/src/SampleApp/Sample/FlowHandler.cs @@ -0,0 +1,182 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using Devlooped.WhatsApp.Flows; +using Microsoft.Azure.Amqp.Framing; +using Microsoft.Extensions.Options; + +namespace Devlooped.WhatsApp; + +static class FlowExtensions +{ + public static WhatsAppHandlerBuilder UseFlowsDemo(this WhatsAppHandlerBuilder builder) + => builder.Use((inner, services) => new FlowHandler(inner)); + + class FlowHandler(IWhatsAppHandler inner) : DelegatingWhatsAppHandler(inner) + { + public override async IAsyncEnumerable HandleAsync(IEnumerable messages, [EnumeratorCancellation] CancellationToken cancellation = default) + { + if (messages.OfType().FirstOrDefault() is { } request) + { + // In this case, we are the sole handlers of this type of message, but + // data from the incoming message could be used to route appropriately. + if (request.Flow == "data") + yield return MockData(request); + else + yield return MockList(request); + + yield break; + } + else if (messages.OfType().FirstOrDefault() is { } flowMessage) + { + // This is a flow message, we can mock the response + yield return flowMessage.Reply( + $""" + ☑️ {flowMessage.Source.Flow} payload: + ``` + {JsonSerializer.Serialize(flowMessage.Data, JsonContext.DefaultOptions)} + ``` + """); + + yield break; + } + + + if (messages.OfType().FirstOrDefault() is not { } message || + message.Content is not TextContent text || + !text.Text.StartsWith("/flow ", StringComparison.OrdinalIgnoreCase)) + { + await foreach (var response in base.HandleAsync(messages, cancellation).WithCancellation(cancellation)) + yield return response; + + yield break; + } + + var flow = text.Text[6..].Trim(); + + // Switches automatically from flow_id to flow_name + if (long.TryParse(flow, out var id)) + yield return message.CallToAction("Comenzar", "Comenzar", id, draft: true); + else + yield return message.CallToAction("Comenzar", "Comenzar", flow, draft: true); + } + + Response MockData(FlowDataRequest flow) => flow.Screen switch + { + "welcome_screen" => flow.DataResponse("confirmation_screen", new + { + message = "Recibido: " + flow.Data.GetProperty("comment").GetString(), + }), + _ => flow.DataResponse("welcome_screen", new + { + agent = "list", + service = flow.ServiceId, + user = flow.UserNumber, + flow = flow.Token.Flow, + }), + }; + + Response MockList(FlowDataRequest flow) => flow.DataResponse("SELECT_LIST", new + { + agent = "list", + user = flow.UserNumber, + service = flow.ServiceId, + lists = new[] + { + new + { + id = "supermercado", + main_content = new { title = "Supermercado" }, + on_click_action = new + { + name = "navigate", + next = new { type = "screen", name = "SUPERMARKET_SCREEN" }, + payload = new { selected_list = "supermercado" } + } + }, + new + { + id = "carniceria", + main_content = new { title = "Carnicería" }, + on_click_action = new + { + name = "navigate", + next = new { type = "screen", name = "BUTCHER_SCREEN" }, + payload = new { selected_list = "carniceria" } + } + }, + new + { + id = "ropa", + main_content = new { title = "Ropa" }, + on_click_action = new + { + name = "navigate", + next = new { type = "screen", name = "CLOTHING_SCREEN" }, + payload = new { selected_list = "ropa" } + } + }, + new + { + id = "ferreteria", + main_content = new { title = "Ferretería" }, + on_click_action = new + { + name = "navigate", + next = new { type = "screen", name = "HARDWARE_SCREEN" }, + payload = new { selected_list = "ferreteria" } + } + } + }, + items = new + { + supermercado = new[] + { + new { id = "leche", title = "Leche entera 1L" }, + new { id = "pan", title = "Pan integral" }, + new { id = "huevos", title = "Huevos docena" }, + new { id = "arroz", title = "Arroz blanco 1kg" }, + new { id = "pasta", title = "Pasta spaghetti 500g" }, + new { id = "aceite", title = "Aceite de oliva 500ml" }, + new { id = "azucar", title = "Azúcar 1kg" }, + new { id = "harina", title = "Harina 1kg" }, + new { id = "sal", title = "Sal fina 500g" }, + new { id = "cafe", title = "Café molido 250g" } + }, + carniceria = new[] + { + new { id = "carne_molida", title = "Carne molida 1kg" }, + new { id = "pollo", title = "Pollo entero 2kg" }, + new { id = "costilla", title = "Costilla de cerdo 1kg" }, + new { id = "filete", title = "Filete de res 500g" }, + new { id = "chorizo", title = "Chorizo artesanal 500g" }, + new { id = "jamon", title = "Jamón serrano 200g" }, + new { id = "salchicha", title = "Salchichas 12 unid" }, + new { id = "pechuga", title = "Pechuga de pollo 1kg" } + }, + ropa = new[] + { + new { id = "camiseta", title = "Camiseta blanca M" }, + new { id = "pantalon", title = "Pantalón vaquero talla 32" }, + new { id = "zapatos", title = "Zapatos deportivos talla 42" }, + new { id = "chaqueta", title = "Chaqueta de cuero L" }, + new { id = "calcetines", title = "Calcetines pack 6 pares" }, + new { id = "cinturon", title = "Cinturón de cuero negro" }, + new { id = "sombrero", title = "Sombrero de lana" }, + new { id = "bufanda", title = "Bufanda de invierno" } + }, + ferreteria = new[] + { + new { id = "martillo", title = "Martillo de carpintero" }, + new { id = "destornillador", title = "Juego de destornilladores 6 piezas" }, + new { id = "clavos", title = "Clavos 2 pulgadas 1kg" }, + new { id = "tornillos", title = "Tornillos para madera 100 unid" }, + new { id = "taladro", title = "Taladro eléctrico 500W" }, + new { id = "pintura", title = "Pintura blanca 4L" }, + new { id = "brocha", title = "Brocha de pintar 2 pulgadas" }, + new { id = "cinta", title = "Cinta métrica 5m" }, + new { id = "sierra", title = "Sierra manual" } + } + } + }); + } +} diff --git a/src/SampleApp/Sample/Program.cs b/src/SampleApp/Sample/Program.cs index f3542d4..fd3fcff 100644 --- a/src/SampleApp/Sample/Program.cs +++ b/src/SampleApp/Sample/Program.cs @@ -15,7 +15,8 @@ var builder = FunctionsApplication.CreateBuilder(args); builder.ConfigureFunctionsWebApplication(); builder.AddServiceDefaults(); -builder.Configuration.AddUserSecrets(); +builder.Configuration.AddUserSecrets() + .AddJsonFile("local.settings.json", optional: false); #if CI || RELEASE builder.Environment.EnvironmentName = "Production"; @@ -58,6 +59,7 @@ .UseOpenTelemetry(builder.Environment.ApplicationName) .UseLogging() .Use(EchoAndHandle) + .UseFlowsDemo() .UseConversation(conversationWindowSeconds: 300 /* default */) .UseConsole(); // Uncomment next line to render a JSON of text message/responses diff --git a/src/SampleApp/Sample/Sample.csproj b/src/SampleApp/Sample/Sample.csproj index 8cdd36a..ade87b7 100644 --- a/src/SampleApp/Sample/Sample.csproj +++ b/src/SampleApp/Sample/Sample.csproj @@ -14,9 +14,9 @@ - - - + + + diff --git a/src/Tests/Content/WhatsApp/FlowData.json b/src/Tests/Content/WhatsApp/FlowData.json new file mode 100644 index 0000000..df4e9ad --- /dev/null +++ b/src/Tests/Content/WhatsApp/FlowData.json @@ -0,0 +1,9 @@ +{ + "data": { + "foo": "bar" + }, + "flow_token": "service:478908188647460;user:541159278282;flow:data;token:asdf1234", + "screen": "Welcome", + "action": "data_exchange", + "version": "3.0" +} \ No newline at end of file diff --git a/src/Tests/Content/WhatsApp/FlowInit.json b/src/Tests/Content/WhatsApp/FlowInit.json new file mode 100644 index 0000000..ed73b8b --- /dev/null +++ b/src/Tests/Content/WhatsApp/FlowInit.json @@ -0,0 +1,7 @@ +{ + "data": {}, + "flow_token": "service:478908188647460;user:541159278282;flow:data;token:asdf1234", + "screen": "", + "action": "INIT", + "version": "3.0" +} \ No newline at end of file diff --git a/src/Tests/Content/WhatsApp/InteractiveFlow.json b/src/Tests/Content/WhatsApp/InteractiveFlow.json new file mode 100644 index 0000000..d3a2536 --- /dev/null +++ b/src/Tests/Content/WhatsApp/InteractiveFlow.json @@ -0,0 +1,46 @@ +{ + "object": "whatsapp_business_account", + "entry": [ + { + "id": "539235785933710", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "14252004598", + "phone_number_id": "478908188647460" + }, + "contacts": [ + { + "profile": { "name": "Kzu" }, + "wa_id": "5491159278282" + } + ], + "messages": [ + { + "context": { + "from": "14252004598", + "id": "wamid.HBgNNTQ5MTE1OTI3ODI4MhUCABEYEjMzNDQxMkFFN0YwNTNENTA5RQA=" + }, + "from": "5491159278282", + "id": "wamid.HBgNNTQ5MTE1OTI3ODI4MhUCABIYIDg5NkVCRDMyNTBGMTgwMUUyQkMyMTIxMTZDMTE2NzY5AA==", + "timestamp": "1755623610", + "type": "interactive", + "interactive": { + "type": "nfm_reply", + "nfm_reply": { + "response_json": "{\"comment\":\"Hola\",\"flow_token\":\"service:478908188647460;user:541159278282;flow:data;token:01K31N40NM6JPJYY0CCWVKN8HE\"}", + "body": "Sent", + "name": "flow" + } + } + } + ] + }, + "field": "messages" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Tests/rsa_public_key.pem b/src/Tests/Content/chebot_rsa_public_key.pem similarity index 100% rename from src/Tests/rsa_public_key.pem rename to src/Tests/Content/chebot_rsa_public_key.pem diff --git a/src/Tests/Content/rsa_private_key.pem b/src/Tests/Content/rsa_private_key.pem new file mode 100644 index 0000000..c0ac453 --- /dev/null +++ b/src/Tests/Content/rsa_private_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAtPXttXQDIGfI/e4e0BfRlZrRd5CNfpI6C1sZv9Co9UqI480G +RqZ0EfB/fOshkqkl/D9PvmBgWr+cJlMhiKsVPUmbKvGbR0jOeKii3nNwsK5V5MzS +r7LPz+l3daL3Q7DmUt0wd9AUXGZ4nyi4FPM8rdlHR7cRsSAxS3mE2lepf758sxYh +rAc8NA34RbKV1Bdh4n3YzZwA04CCpExxc2tc4oxSFCW7wZOWk7lMjw25zWLy1Y8j +wq3ndPpwSEKMGkJ/MZ3gDQfipKwpM/eeYyPyKah9dIGQbeP4Q6b6R4lJQGwCHLIi +sEWnC1frP8jzhCN0BVeO92RU3Eci9sNoRC4OBQIDAQABAoIBAQCguU1HvKKKRaPe +5X+4k28y0S76YwlJQdcL/v3/v/RQ6MJskczhMzYkONcCvFQ1TsbOI+1PyYnLECRA +ZdWC7XEP3jBTFAQ2bBP2VtFRgna0diMT9FesdcTdhR70/KlhFd17+7jwsX9kEh9Q +PXpCIUfjoPgOWir8hvtBbczxoKqLKcWVlGP1D6BBxUn/zAQwHorn47iVOQiZj2Rf +qEYC9uzt9O3o54LcCeSh7I3aqvry4VWWDBsej8Z+lSEWmKlZc/DFVezI2yrmSERG +rnPnUHpdyTY0H6U5Rej5oKfX7HzIuVaJKfnl6/kOiRbnPmER8ZfWg0/mHtDta9B5 +s6d64qCBAoGBAOa392HtzzMvhcFqzBFNQ/BBygB3uuPgzyB9+kEuTCmb1f+v0/nl +Z8rffN0svtHuDnk2xndoEhYiyWfMQMNbE+DpFcpJhlKMgaFPQK7FzgCfdfLW6UC6 ++ag8dK6PNsxdrH64AgPTDjFkUqCTR8MlC4I2rwRvo3gCGYSJH6hzPW4rAoGBAMjK +K5WfB6s16AESQP5r7ujNrIZRKFXdVg3sSCvblTVdpOOAO1QR4FugLQaA4S1D1RI3 +eckEbg5WbtFnoK0VLyLjB+9ZEv9pJWr6BjHOSvVq8CqlGUFf+PB4Tm/H/syXE0E8 +b15ENcZ1nNdWs9FNa1u/DtQYFaAkFEumnq102oyPAoGAehsxLT4MJB3pn2UjXaDT +7QsUmszDN6maVar20JptKrRUPP9Uo/Ray08eqXvt/fMM6/Amd7m3oMmGaI38VKgW +TDlwE+B326aLUNE9/YGotkGuzfgZ+O08BmMAqgYXzW9iRKqkPlvsLg3XgS6Rk2E+ +xwqj9CgVjwUldjSQcbmT8IMCgYB/M4S0/tBu6HGX7CZ8h4gMt+9qEBQLgXK4001N +a4h1DGQfM+dh9Qk7QpgDnLYKZQSgy8A9Vq8aKit+QbYKsHbyFP275aQhZk0sHkS6 +XMQkAaEwgvMi9VfRj4WxTvPeTH8IPu8WuwOOPIgl62lzWSaAMuOD/dYFY5Xv7xhr +LrIdhQKBgFwtauzdKy4d11WUP8NFNY9cgu3VcM8gI2seVFLUXxZRpWBKSIr4+wjq +ZXrhcStzX2Ffsf/P930DsXiroR1DIFgdK0ZcxAgXT+9wr748CcSwI302RXv7yFXH +6AKmLINq+izYBjZOd5zMMLWl+8whUv6VrcAjGRiW4FTmrQzPcrEK +-----END RSA PRIVATE KEY----- diff --git a/src/Tests/Content/rsa_public_key.pem b/src/Tests/Content/rsa_public_key.pem new file mode 100644 index 0000000..ba5df87 --- /dev/null +++ b/src/Tests/Content/rsa_public_key.pem @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAtPXttXQDIGfI/e4e0BfRlZrRd5CNfpI6C1sZv9Co9UqI480GRqZ0 +EfB/fOshkqkl/D9PvmBgWr+cJlMhiKsVPUmbKvGbR0jOeKii3nNwsK5V5MzSr7LP +z+l3daL3Q7DmUt0wd9AUXGZ4nyi4FPM8rdlHR7cRsSAxS3mE2lepf758sxYhrAc8 +NA34RbKV1Bdh4n3YzZwA04CCpExxc2tc4oxSFCW7wZOWk7lMjw25zWLy1Y8jwq3n +dPpwSEKMGkJ/MZ3gDQfipKwpM/eeYyPyKah9dIGQbeP4Q6b6R4lJQGwCHLIisEWn +C1frP8jzhCN0BVeO92RU3Eci9sNoRC4OBQIDAQAB +-----END RSA PUBLIC KEY----- diff --git a/src/Tests/FlowTests.cs b/src/Tests/FlowTests.cs index e0ee08a..fdb120a 100644 --- a/src/Tests/FlowTests.cs +++ b/src/Tests/FlowTests.cs @@ -1,37 +1,380 @@ -using System.Text.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Devlooped.WhatsApp.Flows; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.OData.Client; +using Newtonsoft.Json.Linq; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Encodings; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Utilities.IO.Pem; namespace Devlooped.WhatsApp; public class FlowTests(ITestOutputHelper output) { - [SecretsFact("Meta:PrivateKey")] + [Fact] public void VerifyFlowRequest() { - var configuration = new ConfigurationBuilder() - .AddUserSecrets() - .Build(); + var keyPair = GenerateRsaKeyPair(); + var privatePem = ExportPrivatePem(keyPair); + + var aesKey = RandomBytes(16); // 128-bit per docs + var iv = Convert.FromBase64String("v1U9tB6hUBd4lVDUlaviBg=="); // 16 bytes supported - var options = configuration.GetSection("Meta").Get(); - Assert.NotNull(options?.PrivateKey); + using var crypto = new FlowCryptography(privatePem); - var crypto = new FlowCryptography(options.PrivateKey); + var requestJson = JsonSerializer.Deserialize( + """ + { + "version": "3.0", + "action": "ping" + } + """); + + // Encrypt request payload using same helper as production (unflipped IV for request per spec) + var plain = Encoding.UTF8.GetBytes(requestJson.GetRawText()); + var cipherWithTag = FlowCryptography.AesGcmEncrypt(aesKey, iv, plain); + + // RSA-encrypt AES key with OAEP SHA-256 (encrypted_aes_key) + var encryptedKey = RsaOaepSha256Encrypt(keyPair.Public, aesKey); var request = new EncryptedFlowData( - "0zMVQ5xXwGZ8nViXojCYovFRvrTB3dx2bDA4AhXoPhFirbNlsN9Gi7JYDDoBZ44W6g==", - @"e5JGMhHduIeaynRKPzleeZdcybczOJnTbLZ0nB0wWLYak1IkNbb06ZDNKt29h9A7wCOAJnf3DaWzWR5365z70QMgtN5oZRWkVEJgzNtIsM7vgbT2TZtVTLXuSNQrS4ueqF7s/d6WKLqhdz3+Ab2kebJlFoDbXQxMqVI2HK8qd5jI0lPIALp28tORq+Z3etz3qYW8p1K4ruc77LqYHrdF1YePLES+c5F90WQMt7gtbJMCMoQFPhViKXVOykJ0gChvqCxfu2wH/L0vU9HdhOFK2rZPxq123BvmLCLwSFt+CnQY64iambrTZXz4Z+GhtSCR9O8MBck6mDl9eWT/RAkxbg==", - "v1U9tB6hUBd4lVDUlaviBg=="); + Data: Convert.ToBase64String(cipherWithTag), + Key: Convert.ToBase64String(encryptedKey), + IV: Convert.ToBase64String(iv)); var decrypted = crypto.Decrypt(request); Assert.NotNull(decrypted); + Assert.Equal(aesKey, decrypted.Key); + Assert.Equal(iv, decrypted.IV); output.WriteLine(decrypted.Data.ToString()); + Assert.Equal(requestJson.ToString(), decrypted.Data.ToString()); var response = new { screen = "SCREEN_NAME", data = new { some_key = "some_value" } }; - var encrypted = crypto.Encrypt(new FlowData( + var encryptedResponse = crypto.Encrypt(new FlowData( JsonSerializer.SerializeToElement(response), decrypted.Key, decrypted.IV)); - Assert.NotEmpty(encrypted); + Assert.NotEmpty(encryptedResponse); + } + + [SecretsTheory("Meta:PrivateKey", "SendFrom", "SendTo")] + [InlineData("list")] + [InlineData("data")] + public async Task SendFlow(string flow) + { + var (configuration, client) = Initialize(); + + var message = ContentMessage.Create(configuration["SendFrom"]!, configuration["SendTo"]!, "Hello"); + + var response = message.CallToAction("Flow Demo", "Show Flow", new FlowParameters(flow) + { + Token = Ulid.NewUlid().ToString(), + Action = FlowAction.DataExchange, + Mode = FlowMode.Draft + }); + + var sent = await response.SendAsync(client); + + Assert.NotEqual(response, sent); + } + + [SecretsFact("Meta:PrivateKey", "SendFrom", "SendTo")] + public async Task SendFlowNavigateData() + { + var (configuration, client) = Initialize(); + + var message = ContentMessage.Create(configuration["SendFrom"]!, configuration["SendTo"]!, "Hello"); + + //message.CallToFlow() + + await client.SendAsync(configuration["SendFrom"]!, new + { + messaging_product = "whatsapp", + recipient_type = "individual", + to = configuration["SendTo"]!, + type = "interactive", + interactive = new + { + type = "flow", + body = new + { + text = "Agent Data Demo" + }, + action = new + { + name = "flow", + parameters = new + { + flow_message_version = "3", + flow_cta = "Show Data", + flow_name = "data", + mode = "draft", + flow_token = "agent:list;service:5678;user:pga;flow:data;token:asdf1234", + flow_action = "navigate", + flow_action_payload = new + { + screen = "welcome_screen", + data = new + { + agent = "list", + service = "5678", + user = "pga", + flow = "data", + }, + } + } + } + } + }); + } + + + [SecretsFact("Meta:PrivateKey")] + public async Task SendFlowWithData() + { + var (configuration, client) = Initialize(); + + await client.SendAsync(configuration["SendFrom"]!, new + { + messaging_product = "whatsapp", + recipient_type = "individual", + to = configuration["SendTo"]!, + type = "interactive", + interactive = new + { + type = "flow", + body = new + { + text = "Lista 'Supermercado'" + }, + action = new + { + name = "flow", + parameters = new + { + flow_cta = "Ver/Editar", + flow_message_version = "3", + flow_name = "list", + mode = "draft", + flow_token = "lists", + flow_action = "navigate", + flow_action_payload = new + { + screen = "SELECT_LIST", + data = new + { + agent = "list", + user = "pga", + service = "5678", + lists = new[] + { + new + { + id = "supermercado", + main_content = new { title = "Supermercado" }, + on_click_action = new + { + name = "navigate", + next = new { type = "screen", name = "SUPERMARKET_SCREEN" }, + payload = new { selected_list = "supermercado" } + } + }, + new + { + id = "carniceria", + main_content = new { title = "Carnicería" }, + on_click_action = new + { + name = "navigate", + next = new { type = "screen", name = "BUTCHER_SCREEN" }, + payload = new { selected_list = "carniceria" } + } + }, + new + { + id = "ropa", + main_content = new { title = "Ropa" }, + on_click_action = new + { + name = "navigate", + next = new { type = "screen", name = "CLOTHING_SCREEN" }, + payload = new { selected_list = "ropa" } + } + }, + new + { + id = "ferreteria", + main_content = new { title = "Ferretería" }, + on_click_action = new + { + name = "navigate", + next = new { type = "screen", name = "HARDWARE_SCREEN" }, + payload = new { selected_list = "ferreteria" } + } + } + }, + items = new + { + supermercado = new[] + { + new { id = "leche", title = "Leche entera 1L" }, + new { id = "pan", title = "Pan integral" }, + new { id = "huevos", title = "Huevos docena" }, + new { id = "arroz", title = "Arroz blanco 1kg" }, + new { id = "pasta", title = "Pasta spaghetti 500g" }, + new { id = "aceite", title = "Aceite de oliva 500ml" }, + new { id = "azucar", title = "Azúcar 1kg" }, + new { id = "harina", title = "Harina 1kg" }, + new { id = "sal", title = "Sal fina 500g" }, + new { id = "cafe", title = "Café molido 250g" } + }, + carniceria = new[] + { + new { id = "carne_molida", title = "Carne molida 1kg" }, + new { id = "pollo", title = "Pollo entero 2kg" }, + new { id = "costilla", title = "Costilla de cerdo 1kg" }, + new { id = "filete", title = "Filete de res 500g" }, + new { id = "chorizo", title = "Chorizo artesanal 500g" }, + new { id = "jamon", title = "Jamón serrano 200g" }, + new { id = "salchicha", title = "Salchichas 12 unid" }, + new { id = "pechuga", title = "Pechuga de pollo 1kg" } + }, + ropa = new[] + { + new { id = "camiseta", title = "Camiseta blanca M" }, + new { id = "pantalon", title = "Pantalón vaquero talla 32" }, + new { id = "zapatos", title = "Zapatos deportivos talla 42" }, + new { id = "chaqueta", title = "Chaqueta de cuero L" }, + new { id = "calcetines", title = "Calcetines pack 6 pares" }, + new { id = "cinturon", title = "Cinturón de cuero negro" }, + new { id = "sombrero", title = "Sombrero de lana" }, + new { id = "bufanda", title = "Bufanda de invierno" } + }, + ferreteria = new[] + { + new { id = "martillo", title = "Martillo de carpintero" }, + new { id = "destornillador", title = "Juego de destornilladores 6 piezas" }, + new { id = "clavos", title = "Clavos 2 pulgadas 1kg" }, + new { id = "tornillos", title = "Tornillos para madera 100 unid" }, + new { id = "taladro", title = "Taladro eléctrico 500W" }, + new { id = "pintura", title = "Pintura blanca 4L" }, + new { id = "brocha", title = "Brocha de pintar 2 pulgadas" }, + new { id = "cinta", title = "Cinta métrica 5m" }, + new { id = "sierra", title = "Sierra manual" } + } + } + } + } + } + } + } + }); + } + + [Fact] + public void DeserializeMessage() + { + var data = JsonSerializer.Deserialize(File.ReadAllText($"Content/WhatsApp/FlowInit.json"), JsonContext.DefaultOptions); + var json = JsonObject.Create(data, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(json); + + json.Add("service", "1234"); + json.Add("user", "5678"); + + var message = JsonSerializer.Deserialize(json, JsonContext.DefaultOptions); + + Assert.NotNull(message); + Assert.Equal("1234", message.ServiceId); + Assert.Equal("5678", message.UserNumber); + Assert.Equal(FlowDataAction.Init, message.Action); + } + + [Fact] + public void DeserializeDataMessage() + { + var data = JsonSerializer.Deserialize(File.ReadAllText($"Content/WhatsApp/FlowData.json")); + var json = JsonObject.Create(data, new JsonNodeOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(json); + + json.Add("service", "1234"); + json.Add("user", "5678"); + + var manual = JsonSerializer.Serialize(new FlowDataRequest("1234", "5678", FlowDataAction.DataExchange, "Welcome", + JsonSerializer.SerializeToElement(new JsonObject { ["foo"] = "bar" }), + FlowToken.Decode("agent:list;service:1234;user:5678;flow:data;token:asdf1234")), JsonContext.DefaultOptions); + + var message = JsonSerializer.Deserialize(json, JsonContext.DefaultOptions); + + Assert.NotNull(message); + Assert.Equal("1234", message.ServiceId); + Assert.Equal("5678", message.UserNumber); + Assert.Equal(FlowDataAction.DataExchange, message.Action); + Assert.Equal("Welcome", message.Screen); + Assert.Equal("bar", message.Data.GetProperty("foo").GetString()); + } + + static AsymmetricCipherKeyPair GenerateRsaKeyPair() + { + var keyGen = new RsaKeyPairGenerator(); + keyGen.Init(new KeyGenerationParameters(new SecureRandom(), 2048)); + return keyGen.GenerateKeyPair(); + } + + static string ExportPrivatePem(AsymmetricCipherKeyPair keyPair) + { + var privateKeyInfo = PrivateKeyInfoFactory.CreatePrivateKeyInfo(keyPair.Private); + using var sw = new StringWriter(); + var pemWriter = new PemWriter(sw); + pemWriter.WriteObject(new PemObject("PRIVATE KEY", privateKeyInfo.GetEncoded())); + pemWriter.Writer.Flush(); + return sw.ToString(); + } + + static byte[] RandomBytes(int length) + { + var bytes = new byte[length]; + new SecureRandom().NextBytes(bytes); + return bytes; + } + + static byte[] RsaOaepSha256Encrypt(AsymmetricKeyParameter publicKey, byte[] data) + { + var oaep = new OaepEncoding(new RsaEngine(), new Sha256Digest(), new Sha256Digest(), null); + oaep.Init(true, publicKey); + return oaep.ProcessBlock(data, 0, data.Length); + } + + (IConfiguration configuration, WhatsAppClient client) Initialize() + { + var configuration = new ConfigurationBuilder() + .AddUserSecrets() + .Build(); + + var collection = new ServiceCollection() + .AddSingleton(new MockLogger(output)) + .AddHttpClient() + .AddSingleton(configuration); + + collection.AddOptions() + .BindConfiguration("Meta") + .ValidateDataAnnotations(); + + collection.AddSingleton(); + + var services = collection.BuildServiceProvider(); + return (configuration, services.GetRequiredService()); } } diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 507246e..d45fbd7 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -43,7 +43,7 @@ - + diff --git a/src/Tests/WhatsAppModelTests.cs b/src/Tests/WhatsAppModelTests.cs index e485c1c..b11391c 100644 --- a/src/Tests/WhatsAppModelTests.cs +++ b/src/Tests/WhatsAppModelTests.cs @@ -167,6 +167,23 @@ public async Task DeserializeInteractiveList() Assert.Equal("Conversación", interactive.Selection.Text); } + [Fact] + public async Task DeserializeInteractiveFlow() + { + var json = await File.ReadAllTextAsync($"Content/WhatsApp/InteractiveFlow.json"); + var message = await Message.DeserializeAsync(json); + + var interactive = Assert.IsType(message); + + Assert.NotNull(message); + Assert.NotNull(message.NotificationId); + Assert.NotNull(message.Service); + Assert.NotNull(message.User); + Assert.Equal("Hola", interactive.Data.GetProperty("comment").GetString()); + Assert.NotNull(interactive.Source); + Assert.Equal("data", interactive.Source.Flow); + } + [Fact] public async Task DeserializeUnsupported() { diff --git a/src/WhatsApp/AzureFunctionsWebhook.cs b/src/WhatsApp/AzureFunctionsWebhook.cs index 01bf547..5334176 100644 --- a/src/WhatsApp/AzureFunctionsWebhook.cs +++ b/src/WhatsApp/AzureFunctionsWebhook.cs @@ -1,9 +1,13 @@ -using System.Text; +using System.Diagnostics; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using Azure.Data.Tables; +using Devlooped.WhatsApp.Flows; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -20,9 +24,12 @@ namespace Devlooped.WhatsApp; class AzureFunctionsWebhook( TableServiceClient tableClient, IMessageProcessor messageProcessor, + PipelineRunner runner, IWhatsAppClient whatsapp, + IWhatsAppHandler handler, IOptions metaOptions, IOptions functionOptions, + IHostEnvironment hosting, ILogger logger) { readonly WhatsAppOptions functionOptions = functionOptions.Value; @@ -53,7 +60,57 @@ public async Task Message([HttpTrigger(AuthorizationLevel.Anonymo new { data = new { status = "active" } }))); } - // TODO: else, how do we handle flow actions? + if (!data.Data.TryGetProperty("flow_token", out var t) || + t.ValueKind != JsonValueKind.String || t.GetString() is not { Length: > 0 } value || + !FlowToken.TryDecode(value, out var token)) + { + logger.LogWarning("Received flow request without a valid flow_token."); + return new BadRequestObjectResult("Missing or invalid flow_token."); + } + + var node = JsonObject.Create(data.Data); + Debug.Assert(node != null, "Node should not be null after decryption."); + + node.Add("service", token.ServiceId); + node.Add("user", token.UserNumber); + + var flow = JsonSerializer.Deserialize(node, JsonContext.DefaultOptions); + if (flow?.Flow is null) + { + logger.LogWarning("Failed to deserialize flow message from: {Json}", json); + return new BadRequestObjectResult("Invalid flow message format."); + } + + FlowDataResponse? flowResponse = default; + + await foreach (var response in handler.HandleAsync([flow])) + { + if (response is FlowDataResponse fdr) + { + if (flowResponse is not null) + { + logger.LogWarning("At most one flow data response can be provided for {Token}", token.RawToken); + return new ConflictObjectResult("Multiple flow data responses are not allowed."); + } + else + { + flowResponse = fdr; + } + } + } + + if (flowResponse is null) + { + logger.LogWarning("No flow data response provided for {Token}", token.RawToken); + return new NotFoundObjectResult("No flow data response provided."); + } + + return new OkObjectResult(crypto.Encrypt(data.With( + new + { + screen = flowResponse.Screen, + data = flowResponse.Data + }))); } if (await WhatsApp.Message.DeserializeAsync(json) is { } message) @@ -75,8 +132,12 @@ public async Task Message([HttpTrigger(AuthorizationLevel.Anonymo if (functionOptions.ReactOnMessage != null && message.Type == MessageType.Content) await message.React(functionOptions.ReactOnMessage).SendAsync(whatsapp).Ignore(); - // Otherwise, enqueue the message processing - await messageProcessor.EnqueueAsync(json); + if (hosting.IsDevelopment()) + // Process inline to speed up local devloop + await runner.ProcessAsync(json); + else + // Otherwise, enqueue the message processing + await messageProcessor.EnqueueAsync(json); } else { diff --git a/src/WhatsApp/CallToActionResponse.cs b/src/WhatsApp/CallToActionResponse.cs index f991718..4e8bacd 100644 --- a/src/WhatsApp/CallToActionResponse.cs +++ b/src/WhatsApp/CallToActionResponse.cs @@ -9,7 +9,7 @@ /// The content of the message calling to action. /// The action button text. /// The URL to navigate to when the action button is clicked. -public record CallToActionResponse(string ServiceId, string UserNumber, string Text, string Action, string Url) : Response(ServiceId, UserNumber, null) +public record CallToActionResponse(string ServiceId, string UserNumber, string Text, string Action, string Url) : Response(ServiceId, UserNumber) { readonly CompositeService? service; diff --git a/src/WhatsApp/CallToFlowResponse.cs b/src/WhatsApp/CallToFlowResponse.cs new file mode 100644 index 0000000..43f81d0 --- /dev/null +++ b/src/WhatsApp/CallToFlowResponse.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using Devlooped.WhatsApp.Flows; + +namespace Devlooped.WhatsApp; + +/// +/// Represents an interactive call to initiate a flow that can be sent in response to a user message. +/// +/// +/// The identifier of the service handling the message. +/// The phone number of the recipient in international format. +/// The content of the message calling to initiate the flow. +/// The action button text. +public record CallToFlowResponse : Response +{ + internal CallToFlowResponse(string serviceId, string userNumber, string text, string action, FlowParameters flow) : base(serviceId, userNumber) + { + Text = text; + Action = action; + Flow = flow; + } + + /// Initializes a new instance of the record using an existing message. + public CallToFlowResponse(IMessage message, string text, string action, long flowId) + : this(message.ServiceId, message.UserNumber, text, action, new FlowParameters(flowId)) + { } + + /// Initializes a new instance of the record using an existing message. + public CallToFlowResponse(IMessage message, string text, string action, string flowName) + : this(message.ServiceId, message.UserNumber, text, action, new FlowParameters(flowName)) + { } + + /// The text message that prompts the user to initiate the flow via the button. + public string Text { get; } + + /// The call to action button text. + public string Action { get; } + + /// Additional parameters for the flow to be initiated. + public FlowParameters Flow { get; init; } + + protected override async Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellation = default) + { + if (Flow.Action == FlowAction.DataExchange && Flow.Payload != null) + throw new NotSupportedException("Payload data can only be provided for Navigate flow action."); + + var token = FlowToken.Encode(this); + + object parameters = Flow.Id.HasValue ? + new + { + flow_message_version = "3", + flow_cta = Action, + flow_id = Flow.Id, + mode = Flow.Mode.ToString().ToLowerInvariant(), + flow_token = token, + flow_action = Flow.Action == FlowAction.DataExchange ? "data_exchange" : "navigate", + flow_action_payload = Flow.Payload + } : + new + { + flow_message_version = "3", + flow_cta = Action, + flow_name = Flow.Name, + mode = Flow.Mode.ToString().ToLowerInvariant(), + flow_token = token, + flow_action = Flow.Action == FlowAction.DataExchange ? "data_exchange" : "navigate", + flow_action_payload = Flow.Payload + }; + + var id = await client.SendAsync(ServiceId, new + { + messaging_product = "whatsapp", + recipient_type = "individual", + to = UserNumber, + type = "interactive", + interactive = new + { + type = "flow", + body = new + { + text = Text + }, + action = new + { + name = "flow", + parameters + } + } + }); + + return id; + } +} + +/// Parameters for initiating or continuing a flow. +public record FlowParameters +{ + /// Initializes a new instance of the record using a flow identifier. + public FlowParameters(long flowId) => Id = flowId; + + /// Initializes a new instance of the record using a flow name. + public FlowParameters(string flowName) => Name = flowName; + + /// Gets the flow identifier, if the instance was constructed with it. + public long? Id { get; } + + /// Gets the flow name, if the instance was constructed with it. + public string? Name { get; } + + /// Indicates the action to perform when the flow is initiated. Defaults to . + public FlowAction Action { get; set; } = FlowAction.Navigate; + + /// Indicates the mode of the flow, either draft or published. + public FlowMode Mode { get; set; } = FlowMode.Published; + + /// Optional data payload for the flow, only valid when is . + public JsonElement? Payload { get; set; } + + /// Optional token to continue the flow, used for resuming or continuing a flow session. + public string? Token { get; set; } +} diff --git a/src/WhatsApp/ContentMessage.cs b/src/WhatsApp/ContentMessage.cs index 7957169..c2066a4 100644 --- a/src/WhatsApp/ContentMessage.cs +++ b/src/WhatsApp/ContentMessage.cs @@ -15,4 +15,14 @@ public record ContentMessage(string Id, Service Service, User User, long Timesta /// [JsonIgnore] public override MessageType Type => MessageType.Content; + + /// + /// Creates a simple text message with the given service ID, user number, and text content. + /// + public static ContentMessage Create(string serviceId, string userNumber, string text) => new ContentMessage( + Ulid.NewUlid().ToString(), + new Service(serviceId, serviceId), + new User(userNumber, userNumber), + DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + new TextContent(text)); } diff --git a/src/WhatsApp/Flows/FlowAction.cs b/src/WhatsApp/Flows/FlowAction.cs new file mode 100644 index 0000000..7936242 --- /dev/null +++ b/src/WhatsApp/Flows/FlowAction.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp.Flows; + +#if NET9_0_OR_GREATER +#else +[JsonConverter(typeof(FlowActionJsonConverter))] +#endif +public enum FlowAction +{ + /// Causes the flow to be navigated to a specific screen without involving a server request. + /// The default if not specified for . + Navigate, + /// Causes the flow to be initialized by a data exchange request to the server. +#if NET9_0_OR_GREATER + [JsonStringEnumMemberName("data_exchange")] +#endif + DataExchange, +} + +// If we drop .net8.0, we can just use the built-in JsonStringEnumMemberName attribute +// and remove the custom converter. +class FlowActionJsonConverter : JsonConverter +{ + public override FlowAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType != JsonTokenType.String ? throw new JsonException() : + reader.GetString()!.ToLowerInvariant() switch + { + "navigate" => FlowAction.Navigate, + "data_exchange" or "dataexchange" => FlowAction.DataExchange, + _ => throw new JsonException() + }; + public override void Write(Utf8JsonWriter writer, FlowAction value, JsonSerializerOptions options) + => writer.WriteStringValue(value == FlowAction.Navigate ? "navigate" : "data_exchange"); +} \ No newline at end of file diff --git a/src/WhatsApp/FlowMessage.cs b/src/WhatsApp/Flows/FlowCryptography.cs similarity index 90% rename from src/WhatsApp/FlowMessage.cs rename to src/WhatsApp/Flows/FlowCryptography.cs index d4cdac0..e9f1a1d 100644 --- a/src/WhatsApp/FlowMessage.cs +++ b/src/WhatsApp/Flows/FlowCryptography.cs @@ -7,19 +7,19 @@ using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Parameters; -namespace Devlooped.WhatsApp; +namespace Devlooped.WhatsApp.Flows; /// Represents an encrypted flow message exchanged with WhatsApp Business API. -public record EncryptedFlowData( +record EncryptedFlowData( [property: JsonPropertyName("encrypted_flow_data")] string Data, [property: JsonPropertyName("encrypted_aes_key")] string Key, [property: JsonPropertyName("initial_vector")] string IV); /// Parsed flow data containing decrypted JSON and AES key/IV. -public record FlowData(TData Data, byte[] Key, byte[] IV); +record FlowData(TData Data, byte[] Key, byte[] IV); /// Represents flow data with decrypted JSON content, AES key, and IV. -public record FlowData(JsonElement Data, byte[] Key, byte[] IV) : FlowData(Data, Key, IV) +record FlowData(JsonElement Data, byte[] Key, byte[] IV) : FlowData(Data, Key, IV) { /// Creates a new instance of with the specified data. public FlowData With(TData data) => @@ -27,7 +27,7 @@ public FlowData With(TData data) => } /// Implements the flow message encryption and decryption for the WhatsApp Business API. -public class FlowCryptography : IDisposable +class FlowCryptography : IDisposable { const int TagLengthBytes = 16; const int StandardNonceLength = 12; @@ -44,14 +44,14 @@ public FlowCryptography(IOptions options) public FlowCryptography(string privatePem) { rsa = RSA.Create(); - rsa.ImportFromPem(privatePem); + rsa.ImportFromPem(Throw.IfNullOrEmpty(privatePem)); } /// Initializes the class with the provided RSA private key in PEM format and a passphrase for decryption. public FlowCryptography(string privatePem, string passphrase) { rsa = RSA.Create(); - rsa.ImportFromEncryptedPem(privatePem, passphrase); + rsa.ImportFromEncryptedPem(Throw.IfNullOrEmpty(privatePem), passphrase); } /// Decrypts the provided encrypted flow data into a object. @@ -123,7 +123,8 @@ static byte[] AesGcmDecrypt(byte[] key, byte[] iv, byte[] input) } } - static byte[] AesGcmEncrypt(byte[] key, byte[] iv, byte[] plain) + // Made internal so tests can reuse the exact implementation for client-side payload generation without duplicating logic. + internal static byte[] AesGcmEncrypt(byte[] key, byte[] iv, byte[] plain) { if (iv.Length < StandardNonceLength) throw new ArgumentException("IV must be at least 12 bytes."); diff --git a/src/WhatsApp/Flows/FlowDataAction.cs b/src/WhatsApp/Flows/FlowDataAction.cs new file mode 100644 index 0000000..7a96182 --- /dev/null +++ b/src/WhatsApp/Flows/FlowDataAction.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp.Flows; + +#if NET9_0_OR_GREATER +#else +[JsonConverter(typeof(FlowDataActionJsonConverter))] +#endif +public enum FlowDataAction +{ +#if NET9_0_OR_GREATER + [JsonStringEnumMemberName("INIT")] +#endif + Init, +#if NET9_0_OR_GREATER + [JsonStringEnumMemberName("BACK")] +#endif + Back, +#if NET9_0_OR_GREATER + [JsonStringEnumMemberName("data_exchange")] +#endif + DataExchange, +} + +// If we drop .net8.0, we can just use the built-in JsonStringEnumMemberName attribute +// and remove the custom converter. +class FlowDataActionJsonConverter : JsonConverter +{ + public override FlowDataAction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType != JsonTokenType.String ? throw new JsonException() : + reader.GetString()!.ToUpperInvariant() switch + { + "INIT" => FlowDataAction.Init, + "BACK" => FlowDataAction.Back, + "DATA_EXCHANGE" or "DATAEXCHANGE" => FlowDataAction.DataExchange, + _ => throw new JsonException() + }; + + public override void Write(Utf8JsonWriter writer, FlowDataAction value, JsonSerializerOptions options) + => writer.WriteStringValue(value switch + { + FlowDataAction.Init => "INIT", + FlowDataAction.Back => "BACK", + FlowDataAction.DataExchange => "data_exchange", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, null) + }); +} \ No newline at end of file diff --git a/src/WhatsApp/Flows/FlowDataExchange.cs b/src/WhatsApp/Flows/FlowDataExchange.cs new file mode 100644 index 0000000..fb6c707 --- /dev/null +++ b/src/WhatsApp/Flows/FlowDataExchange.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Devlooped.WhatsApp.Flows; + +/// An incoming flow data exchange message, which initiates or continues a flow session. +public record FlowDataRequest( + [property: JsonPropertyName("service")] string ServiceId, + [property: JsonPropertyName("user")] string UserNumber, + FlowDataAction Action, string Screen, JsonElement Data, + [property: JsonPropertyName("flow_token")] FlowToken Token) : IMessage +{ + /// + [JsonConverter(typeof(AdditionalPropertiesDictionaryConverter))] + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Gets the . value. + public string Flow => Token.Flow; + + public string Id => $"{Token.RawToken};ts:{Timestamp}"; + + public long Timestamp { get; init; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + [JsonIgnore] + public string? Context => default; + + [JsonIgnore] + public MessageType Type => MessageType.FlowData; +} + +/// Provides the DataResponse methods for a flow data exchange request. +public static class FlowDataRequestExtensions +{ + /// Creates a for the given . + public static FlowDataResponse DataResponse(this FlowDataRequest message, string screen, JsonElement data) + => new(message.ServiceId, message.UserNumber, screen, data); + + /// Creates a for the given . + public static FlowDataResponse DataResponse(this FlowDataRequest message, string screen, T data) + => new(message.ServiceId, message.UserNumber, screen, JsonSerializer.SerializeToElement(data, JsonContext.DefaultOptions)); +} + +/// Represents a response to a flow data exchange request, which is consumed by the flow. +public record FlowDataResponse(string ServiceId, string UserNumber, string Screen, JsonElement Data) : Response(ServiceId, UserNumber) +{ + /// The flow response is not actually sent via the client, but rather processed by the webhook itself. + protected override Task SendCoreAsync(IWhatsAppClient client, CancellationToken cancellation = default) + => Task.FromResult(Ulid.NewUlid().ToString()); +} \ No newline at end of file diff --git a/src/WhatsApp/Flows/FlowMode.cs b/src/WhatsApp/Flows/FlowMode.cs new file mode 100644 index 0000000..0e65abd --- /dev/null +++ b/src/WhatsApp/Flows/FlowMode.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Devlooped.WhatsApp; + +/// Indicates the mode of the flow, either draft or published. +public enum FlowMode +{ + Draft, + Published +} \ No newline at end of file diff --git a/src/WhatsApp/Flows/FlowToken.cs b/src/WhatsApp/Flows/FlowToken.cs new file mode 100644 index 0000000..3ff1ac9 --- /dev/null +++ b/src/WhatsApp/Flows/FlowToken.cs @@ -0,0 +1,77 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devlooped.WhatsApp.Flows; + +/// A wrapper around a flow token that encodes required information to identify the flow, user, service and any additional data required by the service. +[JsonConverter(typeof(FlowTokenConverter))] +public class FlowToken +{ + FlowToken(IDictionary data, string raw) + => (Data, RawToken) = (data.AsReadOnly(), raw); + + /// Gets the raw key-value data contained in the token. + public IReadOnlyDictionary Data { get; } + + /// Gets the original token content used to decode this instance. + public string RawToken { get; } + + /// Gets the service identifier from the token data. + public string ServiceId => Data.TryGetValue("service", out var service) ? service : throw new KeyNotFoundException("service"); + + /// Gets the user phone number from the token data. + public string UserNumber => Data.TryGetValue("user", out var user) ? user : throw new KeyNotFoundException("user"); + + /// Gets the flow identifier or name from the token data. + public string Flow => Data.TryGetValue("flow", out var flow) ? flow : throw new KeyNotFoundException("flow"); + + /// Encodes the given response message as a token for use when starting a flow. + public static string Encode(CallToFlowResponse message) + { + var sb = new StringBuilder() + .Append("service:").Append(message.ServiceId).Append(';') + .Append("user:").Append(message.UserNumber).Append(';') + .Append("flow:").Append(message.Flow.Name ?? message.Flow.Id?.ToString() ?? throw new ArgumentException("Either flow name or id is required")); + + if (!string.IsNullOrEmpty(message.Flow.Token)) + sb.Append(';').Append("token:").Append(message.Flow.Token); + + return sb.ToString(); + } + + public static FlowToken Decode(string token) => TryDecode(token, out var flow) ? flow : throw new FormatException("Invalid flow token format."); + + /// Attempts to decode the given token string into a instance. + public static bool TryDecode(string token, [NotNullWhen(true)] out FlowToken? flow) + { + var parts = token.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var part in parts) + { + var kv = part.Split(':', 2); + if (kv.Length == 2) + dict[kv[0]] = kv[1]; + } + + if (!dict.ContainsKey("service") || !dict.ContainsKey("user") || !dict.ContainsKey("flow")) + { + flow = null; + return false; + } + + flow = new FlowToken(dict, token); + return true; + } + + class FlowTokenConverter : JsonConverter + { + public override FlowToken Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType != JsonTokenType.String || !TryDecode(reader.GetString()!, out var token) ? + throw new JsonException("Invalid flow token format.") : token; + + public override void Write(Utf8JsonWriter writer, FlowToken value, JsonSerializerOptions options) + => writer.WriteStringValue(value.RawToken); + } +} diff --git a/src/WhatsApp/InteractiveFlowMessage.cs b/src/WhatsApp/InteractiveFlowMessage.cs new file mode 100644 index 0000000..3219ced --- /dev/null +++ b/src/WhatsApp/InteractiveFlowMessage.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Devlooped.WhatsApp.Flows; + +namespace Devlooped.WhatsApp; + +/// +/// A containing an interactive reply for either a button or list message. +/// +/// The message identifier. +/// The service that received the message from the Cloud API. +/// The user that sent the message. +/// Timestamp of the message. +/// The payload sent by the complete flow action. +public record InteractiveFlowMessage(string Id, Service Service, User User, long Timestamp, JsonElement Data, FlowToken Source) : UserMessage(Id, Service, User, Timestamp) +{ + /// + [JsonIgnore] + public override MessageType Type => MessageType.InteractiveFlow; +} \ No newline at end of file diff --git a/src/WhatsApp/JsonContext.Internal.cs b/src/WhatsApp/JsonContext.Internal.cs index b383d0f..c4f2b0d 100644 --- a/src/WhatsApp/JsonContext.Internal.cs +++ b/src/WhatsApp/JsonContext.Internal.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Devlooped.WhatsApp.Flows; namespace Devlooped.WhatsApp; @@ -15,6 +16,7 @@ namespace Devlooped.WhatsApp; [JsonSerializable(typeof(ErrorResponse))] [JsonSerializable(typeof(SendResponse))] [JsonSerializable(typeof(MessageId))] +[JsonSerializable(typeof(EncryptedFlowData))] partial class InternalJsonContext : JsonSerializerContext { } diff --git a/src/WhatsApp/JsonContext.cs b/src/WhatsApp/JsonContext.cs index 6dc6422..45ff9a1 100644 --- a/src/WhatsApp/JsonContext.cs +++ b/src/WhatsApp/JsonContext.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using Devlooped.WhatsApp.Flows; using Microsoft.Extensions.AI; namespace Devlooped.WhatsApp; @@ -34,6 +35,8 @@ namespace Devlooped.WhatsApp; [JsonSerializable(typeof(Message))] [JsonSerializable(typeof(ContentMessage))] [JsonSerializable(typeof(ErrorMessage))] +[JsonSerializable(typeof(FlowDataRequest))] +[JsonSerializable(typeof(FlowToken))] [JsonSerializable(typeof(InteractiveMessage))] [JsonSerializable(typeof(ReactionMessage))] [JsonSerializable(typeof(StatusMessage))] @@ -96,7 +99,7 @@ static JsonSerializerOptions CreateDefaultOptions() // If reflection-based serialization is enabled by default, use it as a fallback for all other types. // Also turn on string-based enum serialization for all unknown enums. options.TypeInfoResolverChain.Add(new DefaultJsonTypeInfoResolver()); - options.Converters.Add(new JsonStringEnumConverter()); + //options.Converters.Add(new JsonStringEnumConverter()); } options.MakeReadOnly(); diff --git a/src/WhatsApp/Message.cs b/src/WhatsApp/Message.cs index c3c4951..574fefa 100644 --- a/src/WhatsApp/Message.cs +++ b/src/WhatsApp/Message.cs @@ -15,6 +15,7 @@ namespace Devlooped.WhatsApp; [JsonDerivedType(typeof(ContentMessage), "content")] [JsonDerivedType(typeof(ErrorMessage), "error")] [JsonDerivedType(typeof(InteractiveMessage), "interactive")] +[JsonDerivedType(typeof(InteractiveFlowMessage), "flow")] [JsonDerivedType(typeof(ReactionMessage), "reaction")] [JsonDerivedType(typeof(StatusMessage), "status")] [JsonDerivedType(typeof(UnsupportedMessage), "unsupported")] @@ -56,7 +57,7 @@ public abstract partial record Message(string Id, Service Service, User User, lo var jq = await JQ.ExecuteAsync(json, ThisAssembly.Resources.Message.Text); if (!string.IsNullOrEmpty(jq)) { - var message = JsonSerializer.Deserialize(jq, JsonContext.Default.Message); + var message = JsonSerializer.Deserialize(jq, JsonContext.DefaultOptions); // Fix empty id for system messages if (message is not null && string.IsNullOrEmpty(message.Id)) diff --git a/src/WhatsApp/Message.jq b/src/WhatsApp/Message.jq index 2d74777..dc8741f 100644 --- a/src/WhatsApp/Message.jq +++ b/src/WhatsApp/Message.jq @@ -10,7 +10,27 @@ ($msg.type as $msgType | # Compute context once for all message types (if $msgType == "reaction" then $msg.reaction.message_id else ($msg.context.id // null) end) as $context | - if $msgType == "interactive" or $msgType == "button" then + if $msgType == "interactive" and ($msg.interactive.type == "nfm_reply") then + # Parse response_json once, but keep original string if parsing fails + ($msg.interactive.nfm_reply.response_json | (fromjson? // .)) as $data | + { + "$type": "flow", + "notification": $notification, + "id": $msg.id, + "context": $context, + "timestamp": $msg.timestamp | tonumber, + "service": { + "id": $phone.phone_number_id, + "number": $phone.display_phone_number + }, + "user": { + "name": ($user.profile.name // ""), + "number": $msg.from + }, + "data": $data, + "source": ($data.flow_token // null) + } + elif $msgType == "interactive" or $msgType == "button" then { "$type": "interactive", "notification": $notification, @@ -78,7 +98,8 @@ "name": .name.first_name, "surname": .name.last_name, "numbers": [.phones[] | select(.wa_id? != null) | .wa_id] - }) } + }) + } elif $msgType == "text" then { "$type": "text", @@ -174,4 +195,4 @@ } end ) -) \ No newline at end of file +) diff --git a/src/WhatsApp/MessageExtensions.cs b/src/WhatsApp/MessageExtensions.cs index c6d34cc..3069231 100644 --- a/src/WhatsApp/MessageExtensions.cs +++ b/src/WhatsApp/MessageExtensions.cs @@ -82,10 +82,37 @@ public TemplateResponse Template(MessageTemplate template) /// The content of the message calling to action. /// The action button text. /// The URL to navigate to when the action button is clicked. - public CallToActionResponse CallToAction(string text, string action, string url) + public CallToActionResponse CallToAction(string text, string action, Uri uri) => message is UserMessage user - ? new(user.Service, message.UserNumber, text, action, url) - : new(message.ServiceId, message.UserNumber, text, action, url); + ? new(user.Service, message.UserNumber, text, action, uri.AbsoluteUri) + : new(message.ServiceId, message.UserNumber, text, action, uri.AbsoluteUri); + + /// + /// Sends an interactive call to initiate a flow response to the user message. + /// + /// The content of the message calling to action. + /// The action button text. + /// The name of the flow to initiate. + public CallToFlowResponse CallToAction(string text, string action, string flowName, bool draft = false) + => new(message.ServiceId, message.UserNumber, text, action, new FlowParameters(flowName) { Mode = draft ? FlowMode.Draft : FlowMode.Published }); + + /// + /// Sends an interactive call to initiate a flow response to the user message. + /// + /// The content of the message calling to action. + /// The action button text. + /// The id of the flow to initiate. + public CallToFlowResponse CallToAction(string text, string action, long flowId, bool draft = false) + => new(message.ServiceId, message.UserNumber, text, action, new FlowParameters(flowId) { Mode = draft ? FlowMode.Draft : FlowMode.Published }); + + /// + /// Sends an interactive call to initiate a flow response to the user message. + /// + /// The content of the message calling to action. + /// The action button text. + /// The id of the flow to initiate. + public CallToFlowResponse CallToAction(string text, string action, FlowParameters flow) + => new(message.ServiceId, message.UserNumber, text, action, flow); /// /// Creates a text reply for the message. diff --git a/src/WhatsApp/MessageType.cs b/src/WhatsApp/MessageType.cs index d0562c0..92f66db 100644 --- a/src/WhatsApp/MessageType.cs +++ b/src/WhatsApp/MessageType.cs @@ -14,10 +14,18 @@ public enum MessageType /// Error, /// - /// Message contains a button reply. + /// The message is a flow endpoint data exchange. + /// + FlowData, + /// + /// Message contains a button or list selection reply. /// Interactive, /// + /// Message contains the final reply after completing an interactive flow. + /// + InteractiveFlow, + /// /// Message contains a reaction to a message. /// Reaction, diff --git a/src/WhatsApp/Response.cs b/src/WhatsApp/Response.cs index 7af5f5e..3c3633e 100644 --- a/src/WhatsApp/Response.cs +++ b/src/WhatsApp/Response.cs @@ -11,7 +11,7 @@ namespace Devlooped.WhatsApp; /// The identifier of the service to use to send the response through. /// The phone number of the recipient in international format. /// Optional identifier of the message to which this response may be a reply to. -public abstract partial record Response(string ServiceId, string UserNumber, string? Context) : IMessage +public abstract partial record Response(string ServiceId, string UserNumber, string? Context = null) : IMessage { /// /// Creates an anonymous response that uses a function to send the message. diff --git a/src/WhatsApp/WhatsAppClientExtensions.cs b/src/WhatsApp/WhatsAppClientExtensions.cs index d3f4c4f..39cb2b5 100644 --- a/src/WhatsApp/WhatsAppClientExtensions.cs +++ b/src/WhatsApp/WhatsAppClientExtensions.cs @@ -577,7 +577,7 @@ public static Task SendTyping(this IWhatsAppClient client, string serviceId, str parameters = new { display_text = action, - url = url + url } } }