@@ -36,17 +36,18 @@ def __init__(
36
36
async def extract_relevant_whatsapp_message_details (
37
37
self ,
38
38
body : dict [str , Any ],
39
- ) -> tuple [str , str , str ] | str | None :
39
+ ) -> tuple [str , str , dict ] | str :
40
40
"""Extracts relevant whatsapp message details from the incoming webhook payload.
41
41
42
42
Args:
43
43
body (Dict[str, Any]): The JSON body of the incoming request.
44
44
45
45
Returns:
46
- Optional[Tuple[str, str, str]]: A tuple containing the business phone number ID,
47
- the sender's WhatsApp number and the their message (if the extraction is successful).
48
- Returns None if the extraction fails.
46
+ Union[tuple, str]: A tuple of (user_whatsapp_number, incoming_msg_type, incoming_msg_body)
47
+ if successful, or an error message string if it's a status update or other invalid data.
49
48
49
+ Raises:
50
+ Exception: If the payload structure is invalid or unsupported.
50
51
"""
51
52
# logger.debug(f"Received payload from WhatsApp user:\n{body}")
52
53
@@ -70,8 +71,11 @@ async def extract_relevant_whatsapp_message_details(
70
71
# logger.debug(
71
72
# f"WhatsApp status update received:\n({status} at {timestamp}.)",
72
73
# )
73
- return "status update"
74
+ return True , None , None , None , None
75
+ else :
76
+ is_status = False
74
77
78
+ # should never be entered
75
79
if "messages" not in value :
76
80
error_msg = f"Unsupported message type received from WhatsApp user:\n { body } "
77
81
logger .error (
@@ -81,6 +85,8 @@ async def extract_relevant_whatsapp_message_details(
81
85
82
86
incoming_msg = value ["messages" ][0 ]
83
87
88
+ # Extract and store the message ID for use in send_whatsapp_typing_indicator
89
+ message_id = incoming_msg .get ("id" )
84
90
# Extract the phone number of the WhatsApp sender
85
91
user_whatsapp_number = incoming_msg ["from" ]
86
92
# Meta API note: Meta sends "errors" key when receiving unsupported message types
@@ -91,11 +97,7 @@ async def extract_relevant_whatsapp_message_details(
91
97
92
98
logger .info (f"Received a supported whatsapp message from { user_whatsapp_number } : { incoming_msg_body } " )
93
99
94
- return (
95
- user_whatsapp_number ,
96
- incoming_msg_type ,
97
- incoming_msg_body ,
98
- )
100
+ return (is_status , user_whatsapp_number , incoming_msg_type , incoming_msg_body , message_id )
99
101
100
102
async def check_and_register_user (
101
103
self ,
@@ -141,6 +143,44 @@ async def check_and_register_user(
141
143
logger .error (f"Failed to register new whatsapp user: { user_whatsapp_number } " )
142
144
return False
143
145
146
+ async def send_whatsapp_typing_indicator (
147
+ self ,
148
+ user_whatsapp_number : str ,
149
+ message_id : str ,
150
+ ) -> None :
151
+ """Sends a typing indicator to the WhatsApp sender.
152
+
153
+ Args:
154
+ user_whatsapp_number (str): The sender's WhatsApp number.
155
+ message_id (str): The ID of the message being replied to.
156
+
157
+ """
158
+ url = self .meta_api_url
159
+ headers = {
160
+ "Authorization" : f"Bearer { self .access_token } " ,
161
+ "Content-Type" : "application/json" ,
162
+ }
163
+
164
+ try :
165
+ async with httpx .AsyncClient () as client :
166
+ logger .debug (f"SENDING TYPING INDICATOR REQUEST TO: { url } " )
167
+
168
+ json_data = {
169
+ "messaging_product" : "whatsapp" ,
170
+ "status" : "read" ,
171
+ "message_id" : message_id ,
172
+ "typing_indicator" : {"type" : "text" },
173
+ }
174
+
175
+ response = await client .post (url , headers = headers , json = json_data )
176
+ response .raise_for_status () # Raise an exception for HTTP errors
177
+
178
+ logger .debug (f"Sent typing indicator to WhatsApp user { user_whatsapp_number } " )
179
+
180
+ except Exception as e :
181
+ logger .error (f"Error sending typing indicator: { e } . Details are in next log." )
182
+ logger .exception (e )
183
+
144
184
async def send_whatsapp_message (
145
185
self ,
146
186
user_whatsapp_number : str ,
@@ -213,52 +253,101 @@ def _get_retention_time_in_seconds(self) -> int:
213
253
214
254
def _get_whatsapp_markdown (self , msg : str ) -> str :
215
255
"""Convert conventional markdown syntax to WhatsApp's markdown syntax"""
216
-
217
256
msg_direction = get_language_direction_from_text (msg )
218
257
219
- # Replace text surrounded with single "*" with "_"
220
- # (as WhatsApp doesn't support italic text with "*"; it uses "_" instead)
258
+ # Process standard markdown syntax
259
+ msg = self ._convert_italic_syntax (msg )
260
+ msg = self ._convert_bold_syntax (msg )
261
+ msg = self ._convert_headers (msg )
262
+
263
+ # Process lists based on text direction
264
+ if msg_direction in ["ltr" , "rtl" ]:
265
+ msg = self ._format_nested_lists (msg )
266
+
267
+ return msg
268
+
269
+ def _convert_italic_syntax (self , text : str ) -> str :
270
+ """Convert markdown italic syntax (*text*) to WhatsApp italic syntax (_text_)"""
221
271
# Regex details:
222
272
# (?<![\*_]) # Negative lookbehind: Ensures that the '*' is not preceded by '*' or '_'
223
273
# \* # Matches a literal '*'
224
274
# ([^\*_]+?) # Non-greedy match: Captures one or more characters that are not '*' or '_'
225
- # "Captures" mean it can be obtained via \1 in the replacement string
226
275
# \* # Matches a literal '*'
227
276
# (?![\*_]) # Negative lookahead: Ensures that the '*' is not followed by '*' or '_'
277
+ #
278
+ # This pattern carefully identifies standalone italic markers (*text*) while avoiding
279
+ # matching bold markers (**text**) or mixed formatting.
228
280
pattern = re .compile (r"(?<![\*_])\*([^\*_]+?)\*(?![\*_])" )
229
- msg = pattern .sub (r"_\1_" , msg )
230
-
231
- # Replace "**" (markdown bold) with "*" (whatsapp bold)
232
- msg = msg .replace ("**" , "*" )
233
-
234
- # Match headers (#*) (that doesn't have a space before it (i.e., in the middle of a text))
235
- # where there's text directly after them
236
- # NOTE: the `\**_*` part is to neglect any */_ in the returned group (.*?)
281
+ return pattern .sub (r"_\1_" , text )
282
+
283
+ def _convert_bold_syntax (self , text : str ) -> str :
284
+ """Convert markdown bold syntax (**text**) to WhatsApp bold syntax (*text*)"""
285
+ return text .replace ("**" , "*" )
286
+
287
+ def _convert_headers (self , text : str ) -> str :
288
+ """Convert markdown headers to WhatsApp's bold+italic format"""
289
+ # Process headers with content directly after them
290
+ # (?! ) # Ensures there's no space before the hash (avoiding matching in middle of text)
291
+ # #+ \**_* # Matches one or more hash symbols and ignores any bold/italic markers already present
292
+ # (.*?) # Captures the header text (non-greedy)
293
+ # \**_*\n # Matches any trailing formatting markers and the newline
294
+ # (?!\n) # Ensures the newline isn't followed by another newline (i.e., not an isolated header)
237
295
pattern = re .compile (r"(?! )#+ \**_*(.*?)\**_*\n(?!\n)" )
296
+ text = pattern .sub (r"*_\1_*\n\n" , text )
238
297
239
- # Replace them with bold (*) and italic (_) markdown syntax
240
- # and add extra newline (to leave space between header and content)
241
- msg = pattern .sub (r"*_\1_*\n\n" , msg )
242
-
243
- # Match headers (#*) (that doesn't have a space before it (i.e., in the middle of a text))
244
- # where there's another newline directly after them
245
- # NOTE: the `\**_*` part is to neglect any */_ in the returned group (.*?)
298
+ # Process headers with empty line after them
246
299
pattern = re .compile (r"(?! )#+ \**_*(.*?)\**_*\n\n" )
300
+ return pattern .sub (r"*_\1_*\n\n" , text )
247
301
248
- # Replace them with bold (*) and italic (_) markdown syntax
249
- msg = pattern .sub (r"*_\1_*\n\n" , msg )
250
-
251
- # As nested text always appears in left side, even if text is RTL, which could be confusing to the reader,
252
- # we decided to manipulate the nesting symbols (i.e., \d+\. , * , - , etc) so that they appear in right side
253
- # NOTE: added "ltr" for consistency of formatting across different languages
254
- if msg_direction in ["ltr" , "rtl" ]:
255
- # Replace lines that start with (possibly indented) "- " or "* " with "-- "
256
- msg = re .sub (r"(\s*)[\*-] " , r"\1-- " , msg )
302
+ def _format_nested_lists (self , text : str ) -> str :
303
+ """
304
+ Format only nested lists/bullet points with WhatsApp's special formatting.
257
305
258
- # Replace the dot numbered lists (1. , etc.) with a dash (e.g., 1 - )
259
- msg = re .sub (r"(\s*)(\d+)(\.) " , r"\1\2 - " , msg , flags = re .MULTILINE )
306
+ This handles:
307
+ 1. Nested bullet points within numbered lists
308
+ 2. Nested numbered lists within bullet points
309
+ 3. Purely nested bullet points
310
+ 4. Purely nested numbered lists
260
311
261
- return msg
312
+ Simple (non-nested) lists retain their original formatting.
313
+ """
314
+ lines = text .split ("\n " )
315
+ processed_lines = []
316
+ in_nested_section = False
317
+ nested_section_indent = 0
318
+
319
+ for i , line in enumerate (lines ):
320
+ # Check for indentation to detect nesting
321
+ indent_match = re .match (r"^(\s+)" , line ) if line .strip () else None
322
+ current_indent = len (indent_match .group (1 )) if indent_match else 0
323
+
324
+ # Check if this is a list item (numbered or bullet)
325
+ is_numbered_item = re .match (r"^\s*\d+\.\s" , line )
326
+ is_bullet_item = re .match (r"^\s*[\*-]\s" , line )
327
+
328
+ # Determine if we're entering, in, or exiting a nested section
329
+ if (is_numbered_item or is_bullet_item ) and current_indent > 0 :
330
+ # This is a nested item
331
+ if not in_nested_section :
332
+ in_nested_section = True
333
+ nested_section_indent = current_indent
334
+
335
+ # Format nested items
336
+ if is_numbered_item :
337
+ # Convert nested numbered list format: " 1. Item" -> " 1 - Item"
338
+ line = re .sub (r"(\s*)(\d+)(\.) " , r"\1\2 - " , line )
339
+ elif is_bullet_item :
340
+ # Convert nested bullet format: " - Item" or " * Item" -> " -- Item"
341
+ line = re .sub (r"(\s*)[\*-] " , r"\1-- " , line )
342
+
343
+ elif in_nested_section and current_indent < nested_section_indent :
344
+ # We're exiting the nested section
345
+ in_nested_section = False
346
+
347
+ # For non-nested items, leave them as they are
348
+ processed_lines .append (line )
349
+
350
+ return "\n " .join (processed_lines )
262
351
263
352
def _split_long_messages (self , msg_body : str ) -> list [str ]:
264
353
"""Split long messages into smaller chunks based on formatted headers or other patterns.
@@ -274,11 +363,6 @@ def _split_long_messages(self, msg_body: str) -> list[str]:
274
363
275
364
Returns:
276
365
list[str]: A list of message chunks that can be sent separately
277
-
278
- Example:
279
- >>> msg = "*_First Header_*\n Some text here...\n \n *_Second Header_*\n More text..."
280
- >>> _split_long_messages(msg)
281
- ['*_First Header_*\n Some text here...', '*_Second Header_*\n More text...']
282
366
"""
283
367
# WhatsApp character limit
284
368
MAX_LENGTH = 4000
0 commit comments