Skip to content

Commit 39a2296

Browse files
committed
[client] add method to send interactive media carousel messages (suggested feature from discussion #197)
1 parent cb523a5 commit 39a2296

13 files changed

Lines changed: 303 additions & 2 deletions

File tree

docs/source/content/client/client_reference.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Client Reference
1717
.. automethod:: WhatsApp.send_template
1818
.. automethod:: WhatsApp.send_product
1919
.. automethod:: WhatsApp.send_products
20+
.. automethod:: WhatsApp.send_carousel
2021
.. automethod:: WhatsApp.send_reaction
2122
.. automethod:: WhatsApp.remove_reaction
2223
.. automethod:: WhatsApp.mark_message_as_read

docs/source/content/client/overview.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ The client allows you to send a wide variety of messages:
8686
- Send a single product
8787
* - :meth:`~WhatsApp.send_products`
8888
- Send multiple products
89+
* - :meth:`~WhatsApp.send_carousel`
90+
- Send a carousel message
8991
* - :meth:`~WhatsApp.send_reaction`
9092
- React to a message
9193
* - :meth:`~WhatsApp.remove_reaction`

docs/source/content/types/keyboard.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ Keyboard
2121

2222
.. autoclass:: ContactInfoRequestButton()
2323

24+
.. autoclass:: ImageCarouselCard()
25+
26+
.. autoclass:: VideoCarouselCard()
27+
2428
.. autoclass:: CallbackData()
2529

2630
----------------

docs/source/content/updates/common_methods.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ Common methods
99
.. autoclass:: BaseUserUpdate()
1010
:members: sender, recipient, message_id_to_reply,
1111
reply_text, reply_image, reply_video, reply_audio, reply_voice, reply_document, reply_location, reply_location_request,
12-
reply_contact, reply_sticker, reply_template, reply_catalog, reply_product, reply_products, react, unreact,
12+
reply_contact, reply_sticker, reply_template, reply_catalog, reply_product, reply_products, reply_carousel, react, unreact,
1313
mark_as_read, indicate_typing, block_sender, unblock_sender, call

docs/source/content/updates/overview.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ All updates share common methods and properties:
177177
- Reply with a product message
178178
* - :meth:`~BaseUserUpdate.reply_products`
179179
- Reply with a list of product messages
180+
* - :meth:`~BaseUserUpdate.reply_carousel`
181+
- Reply with a carousel message
180182
* - :meth:`~BaseUserUpdate.react`
181183
- React to the update with an emoji
182184
* - :meth:`~BaseUserUpdate.unreact`

pywa/client.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
FlowRequest,
8787
GroupMessageStatuses,
8888
IdentityChange,
89+
ImageCarouselCard,
8990
Industry,
9091
MediaURL,
9192
Message,
@@ -106,9 +107,11 @@
106107
URLButton,
107108
User,
108109
UserMarketingPreferences,
110+
VideoCarouselCard,
109111
VoiceCallButton,
110112
)
111113
from .types.base_update import BaseUpdate
114+
from .types.callback import BaseCarouselCard
112115
from .types.calls import CallPermissions, SessionDescription
113116
from .types.flows import (
114117
CreatedFlow,
@@ -1819,6 +1822,63 @@ def send_products(
18191822
interactive_type=InteractiveType.PRODUCT_LIST,
18201823
)
18211824

1825+
def send_carousel(
1826+
self,
1827+
to: str | int,
1828+
*,
1829+
body: str,
1830+
cards: list[ImageCarouselCard | VideoCarouselCard | BaseCarouselCard],
1831+
reply_to_message_id: str | None = None,
1832+
tracker: str | CallbackData | None = None,
1833+
identity_key_hash: str | None = None,
1834+
sender: str | int | None = None,
1835+
) -> SentMessage:
1836+
"""
1837+
Interactive media carousel messages display a set of horizontally scrollable media cards.
1838+
1839+
- See `Carousel messages <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-media-carousel-messages>`_.
1840+
1841+
Args:
1842+
to: The user phone number, WhatsApp ID, BSUID or group ID to send the message to.
1843+
body: Text to appear in the message body (up to 1024 characters).
1844+
cards: The carousel cards to send (up to 10).
1845+
reply_to_message_id: The message ID to quote (optional).
1846+
tracker: The data to track the message with (optional, up to 512 characters, for complex data you can use :class:`~pywa.types.callback.CallbackData`).
1847+
identity_key_hash: The message would only be delivered if the hash value matches the customer's current hash (Optional, See `Identity Change Check <https://developers.facebook.com/docs/whatsapp/cloud-api/reference/phone-numbers#identity-change-check>`_).
1848+
sender: The phone ID to send the message from (optional, overrides the client's phone ID).
1849+
1850+
Returns:
1851+
The sent carousel message.
1852+
"""
1853+
sender = helpers.resolve_arg(
1854+
wa=self, value=sender, method_arg="sender", client_arg="phone_id"
1855+
)
1856+
1857+
recipient, recipient_type = helpers.resolve_recipient(to)
1858+
return SentMessage.from_sent_update(
1859+
client=self,
1860+
update=self.api.send_message(
1861+
sender=sender,
1862+
**recipient,
1863+
typ="interactive",
1864+
msg=helpers.get_interactive_msg(
1865+
typ=InteractiveType.CAROUSEL,
1866+
action={
1867+
"cards": [
1868+
card.to_dict(idx=idx) for idx, card in enumerate(cards)
1869+
]
1870+
},
1871+
body=body,
1872+
),
1873+
reply_to_message_id=reply_to_message_id,
1874+
biz_opaque_callback_data=helpers.resolve_tracker_param(tracker),
1875+
recipient_identity_key_hash=identity_key_hash,
1876+
),
1877+
from_phone_id=sender,
1878+
recipient_type=recipient_type,
1879+
interactive_type=InteractiveType.PRODUCT_LIST,
1880+
)
1881+
18221882
def mark_message_as_read(
18231883
self,
18241884
message_id: str,

pywa/types/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
SectionRow,
1717
FlowButton,
1818
ContactInfoRequestButton,
19+
VideoCarouselCard,
20+
ImageCarouselCard,
1921
)
2022
from .media import MediaURL, Audio, Document, Image, Sticker, Video
2123
from .message import Message, EditedMessage, DeletedMessage, OutgoingMessage, OutgoingEditedMessage, OutgoingDeletedMessage

pywa/types/base_update.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,15 @@
3131
if TYPE_CHECKING:
3232
from ..client import WhatsApp
3333
from .callback import (
34+
BaseCarouselCard,
3435
Button,
3536
CallbackData,
3637
CallPermissionRequestButton,
3738
FlowButton,
39+
ImageCarouselCard,
3840
SectionList,
3941
URLButton,
42+
VideoCarouselCard,
4043
VoiceCallButton,
4144
)
4245
from .calls import SessionDescription
@@ -1133,6 +1136,44 @@ def reply_template(
11331136
tracker=tracker,
11341137
)
11351138

1139+
def reply_carousel(
1140+
self,
1141+
*,
1142+
body: str,
1143+
cards: list[ImageCarouselCard | VideoCarouselCard | BaseCarouselCard],
1144+
quote: bool = False,
1145+
private: bool = False,
1146+
tracker: str | CallbackData | None = None,
1147+
identity_key_hash: str | None = None,
1148+
sender: str | int | None = None,
1149+
) -> SentMessage:
1150+
"""
1151+
Interactive media carousel messages display a set of horizontally scrollable media cards.
1152+
1153+
- See `Carousel messages <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-media-carousel-messages>`_.
1154+
1155+
Args:
1156+
body: Text to appear in the message body (up to 1024 characters).
1157+
cards: The list of carousel cards (up to 10 cards).
1158+
quote: Whether to quote the replied message (default: False).
1159+
private: Whether to send a private message instead of replying in the same chat (default: False, only applicable for group messages).
1160+
tracker: The data to track the message with (optional, up to 512 characters, for complex data you can use :class:`~pywa.types.callback.CallbackData`).
1161+
identity_key_hash: The message would only be delivered if the hash value matches the customer's current hash (Optional, See `Identity Change Check <https://developers.facebook.com/docs/whatsapp/cloud-api/reference/phone-numbers#identity-change-check>`_).
1162+
sender: The sender of the message (optional, if not provided, ``recipient`` will be used).
1163+
1164+
Returns:
1165+
The sent carousel message.
1166+
"""
1167+
return self._client.send_carousel(
1168+
sender=sender or self._internal_recipient,
1169+
to=self._get_reply_to(private),
1170+
body=body,
1171+
cards=cards,
1172+
reply_to_message_id=self.message_id_to_reply if quote else None,
1173+
identity_key_hash=identity_key_hash,
1174+
tracker=tracker,
1175+
)
1176+
11361177
def mark_as_read(self) -> SuccessResult:
11371178
"""
11381179
Mark the message as read.

pywa/types/callback.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"Section",
1313
"SectionList",
1414
"FlowButton",
15+
"ImageCarouselCard",
16+
"VideoCarouselCard",
1517
"CallbackData",
1618
]
1719

@@ -653,3 +655,86 @@ def to_dict(self) -> dict:
653655
),
654656
},
655657
}
658+
659+
660+
@dataclasses.dataclass(slots=True, kw_only=True)
661+
class BaseCarouselCard:
662+
body: str | None = None
663+
buttons: Iterable[Button] | URLButton
664+
665+
def to_dict(self, idx: int) -> dict:
666+
if isinstance(self.buttons, URLButton):
667+
action = {
668+
"name": "cta_url",
669+
"parameters": {
670+
"display_text": self.buttons.title,
671+
"url": self.buttons.url,
672+
},
673+
}
674+
else:
675+
action = {
676+
"buttons": [
677+
{
678+
"type": "quick_reply",
679+
"quick_reply": {
680+
"id": helpers.resolve_callback_data(b.callback_data),
681+
"title": b.title,
682+
},
683+
}
684+
for b in self.buttons
685+
]
686+
}
687+
payload = {
688+
"card_index": idx,
689+
"type": "cta_url",
690+
"action": action,
691+
}
692+
if self.body is not None:
693+
payload["body"] = {
694+
"text": self.body,
695+
}
696+
return payload
697+
698+
699+
class _BaseMediaCarouselCard(BaseCarouselCard):
700+
_header_type: ClassVar[str]
701+
702+
def to_dict(self, idx: int) -> dict:
703+
common = super().to_dict(idx)
704+
common["header"] = {
705+
"type": self._header_type,
706+
self._header_type: {"link": getattr(self, self._header_type)},
707+
}
708+
return common
709+
710+
711+
@dataclasses.dataclass(slots=True, kw_only=True)
712+
class ImageCarouselCard(_BaseMediaCarouselCard):
713+
"""
714+
Represents a card in a carousel message with an image header.
715+
716+
Attributes:
717+
body: The body text of the card (optional, Max 160 characters, and up to 2 line breaks).
718+
image: Publicly available media asset URL.
719+
buttons: The buttons of the card (either a list of up to 3 :class:`Button` or a single :class:`URLButton`).
720+
"""
721+
722+
_header_type = "image"
723+
724+
image: str
725+
726+
727+
@dataclasses.dataclass(slots=True, kw_only=True)
728+
class VideoCarouselCard(_BaseMediaCarouselCard):
729+
"""
730+
Represents a card in a carousel message with an video header.
731+
732+
Attributes:
733+
body: The body text of the card (optional, Max 160 characters, and up to 2 line breaks).
734+
video: Publicly available media asset URL.
735+
buttons: The buttons of the card (either a list of up to 3 :class:`Button` or a single :class:`URLButton`).
736+
"""
737+
738+
_header_type = "video"
739+
740+
video: str

pywa/types/others.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ class InteractiveType(helpers.StrEnum):
102102
VOICE_CALL = "voice_call"
103103
CALL_PERMISSION_REQUEST = "call_permission_request"
104104
REQUEST_CONTACT_INFO = "request_contact_info"
105+
CAROUSEL = "carousel"
105106

106107
UNKNOWN = "UNKNOWN"
107108

0 commit comments

Comments
 (0)