Skip to content

Commit 85bb370

Browse files
committed
Update WhatsApp API version to v22.0. Add typing indicator
1 parent 670d928 commit 85bb370

File tree

3 files changed

+166
-78
lines changed

3 files changed

+166
-78
lines changed

src/ansari/app/main_whatsapp.py

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -87,45 +87,39 @@ async def main_webhook(request: Request, background_tasks: BackgroundTasks) -> R
8787
Response: HTTP response with status code 200.
8888
8989
"""
90-
# Wait for the incoming webhook message to be received as JSON
91-
data = await request.json()
92-
93-
if get_settings().DEV_MODE:
94-
if "statuses" in (value := data.get("entry", [{}])[0].get("changes", [{}])[0].get("value", {})):
95-
recipient_id = data["entry"][0]["changes"][0]["value"]["statuses"][0].get("recipient_id") or "unknown"
96-
# logger.debug(f"Incoming whatsapp webhook status from {recipient_id}")
97-
pass
98-
else:
99-
recipient_id = ((messages := (value.get("messages", []) or [])) and messages[0].get("from")) or "unknown"
100-
logger.debug(f"Incoming whatsapp webhook message from {recipient_id}")
10190

10291
# # Logging the origin (host) of the incoming webhook message
10392
# logger.debug(f"ORIGIN of the incoming webhook message: {json.dumps(request, indent=4)}")
10493

105-
# Terminate if incoming webhook message is empty/invalid/msg-status-update(sent,delivered,read)
94+
# Wait for the incoming webhook message to be received as JSON
95+
data = await request.json()
96+
97+
# Extract all relevant data in one go
10698
try:
107-
result = await presenter.extract_relevant_whatsapp_message_details(data)
99+
(
100+
is_status,
101+
from_whatsapp_number,
102+
incoming_msg_type,
103+
incoming_msg_body,
104+
message_id,
105+
) = await presenter.extract_relevant_whatsapp_message_details(data)
108106
except Exception as e:
109107
logger.error(f"Error extracting message details: {e}")
110108
return Response(status_code=200)
111-
else:
112-
if isinstance(result, str):
113-
return Response(status_code=200)
114109

115-
# Get relevant info from Meta's API
116-
(
117-
from_whatsapp_number,
118-
incoming_msg_type,
119-
incoming_msg_body,
120-
) = result
110+
if not is_status:
111+
logger.debug(f"Incoming whatsapp webhook message from {from_whatsapp_number}")
112+
else:
113+
# NOTE: This is a status message (e.g., "delivered"), not a user message, so doesn't need processing
114+
return Response(status_code=200)
121115

122116
# Workaround while locally developing:
123117
# don't process other dev's whatsapp recepient phone nums coming from staging env.
124118
# (as both stage Meta app / local-.env-file have same testing number)
125-
dev_num_sub_str = "<your dev. number>"
119+
dev_num_sub_str = "YOUR_DEV_PHONE_NUM"
126120
if get_settings().DEV_MODE and dev_num_sub_str not in from_whatsapp_number:
127121
logger.debug(
128-
f"Incoming message from {from_whatsapp_number} (doesn't have {dev_num_sub_str}). \
122+
f"Incoming message from {from_whatsapp_number} (doesn't have this sub-str: {dev_num_sub_str}). \
129123
Therefore, will not process it as it's not cur. dev."
130124
)
131125
return Response(status_code=200)
@@ -183,10 +177,20 @@ async def main_webhook(request: Request, background_tasks: BackgroundTasks) -> R
183177
# f"Ack: {incoming_msg_text}",
184178
# )
185179

186-
# Send a typing indicator to the sender
187-
# Side note: As of 2024-12-21, Meta's WhatsApp API does not support typing indicators
188-
# Source: Search "typing indicator whatsapp api" on Google
189-
background_tasks.add_task(presenter.send_whatsapp_message, from_whatsapp_number, "...")
180+
# Send a typing indicator if message_id is available,
181+
# otherwise send a placeholder message (should never happen)
182+
if message_id:
183+
background_tasks.add_task(
184+
presenter.send_whatsapp_typing_indicator,
185+
from_whatsapp_number,
186+
message_id,
187+
)
188+
else:
189+
background_tasks.add_task(
190+
presenter.send_whatsapp_message,
191+
from_whatsapp_number,
192+
"...",
193+
)
190194

191195
# Actual code to process the incoming message using Ansari agent then reply to the sender
192196
background_tasks.add_task(

src/ansari/config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def get_resource_path(filename):
176176
SENDGRID_API_KEY: SecretStr | None = Field(default=None)
177177
QURAN_DOT_COM_API_KEY: SecretStr = Field(alias="QURAN_DOT_COM_API_KEY")
178178
WHATSAPP_ENABLED: bool = Field(default=True)
179-
WHATSAPP_API_VERSION: str | None = Field(default="v21.0")
179+
WHATSAPP_API_VERSION: str | None = Field(default="v22.0")
180180
WHATSAPP_BUSINESS_PHONE_NUMBER_ID: SecretStr | None = Field(default=None)
181181
WHATSAPP_ACCESS_TOKEN_FROM_SYS_USER: SecretStr | None = Field(default=None)
182182
WHATSAPP_VERIFY_TOKEN_FOR_WEBHOOK: SecretStr | None = Field(default=None)
@@ -194,14 +194,14 @@ def get_resource_path(filename):
194194
ANTHROPIC_MODEL: str = Field(default="claude-3-7-sonnet-latest")
195195
LOGGING_LEVEL: str = Field(default="INFO")
196196
DEV_MODE: bool = Field(default=False)
197-
197+
198198
# Application settings
199199
MAINTENANCE_MODE: bool = Field(default=False)
200-
200+
201201
# iOS app build versions
202202
IOS_MINIMUM_BUILD_VERSION: int = Field(default=1)
203203
IOS_LATEST_BUILD_VERSION: int = Field(default=1)
204-
204+
205205
# Android app build versions
206206
ANDROID_MINIMUM_BUILD_VERSION: int = Field(default=1)
207207
ANDROID_LATEST_BUILD_VERSION: int = Field(default=1)

src/ansari/presenters/whatsapp_presenter.py

Lines changed: 130 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,18 @@ def __init__(
3636
async def extract_relevant_whatsapp_message_details(
3737
self,
3838
body: dict[str, Any],
39-
) -> tuple[str, str, str] | str | None:
39+
) -> tuple[str, str, dict] | str:
4040
"""Extracts relevant whatsapp message details from the incoming webhook payload.
4141
4242
Args:
4343
body (Dict[str, Any]): The JSON body of the incoming request.
4444
4545
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.
4948
49+
Raises:
50+
Exception: If the payload structure is invalid or unsupported.
5051
"""
5152
# logger.debug(f"Received payload from WhatsApp user:\n{body}")
5253

@@ -70,8 +71,11 @@ async def extract_relevant_whatsapp_message_details(
7071
# logger.debug(
7172
# f"WhatsApp status update received:\n({status} at {timestamp}.)",
7273
# )
73-
return "status update"
74+
return True, None, None, None, None
75+
else:
76+
is_status = False
7477

78+
# should never be entered
7579
if "messages" not in value:
7680
error_msg = f"Unsupported message type received from WhatsApp user:\n{body}"
7781
logger.error(
@@ -81,6 +85,8 @@ async def extract_relevant_whatsapp_message_details(
8185

8286
incoming_msg = value["messages"][0]
8387

88+
# Extract and store the message ID for use in send_whatsapp_typing_indicator
89+
message_id = incoming_msg.get("id")
8490
# Extract the phone number of the WhatsApp sender
8591
user_whatsapp_number = incoming_msg["from"]
8692
# Meta API note: Meta sends "errors" key when receiving unsupported message types
@@ -91,11 +97,7 @@ async def extract_relevant_whatsapp_message_details(
9197

9298
logger.info(f"Received a supported whatsapp message from {user_whatsapp_number}: {incoming_msg_body}")
9399

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)
99101

100102
async def check_and_register_user(
101103
self,
@@ -141,6 +143,44 @@ async def check_and_register_user(
141143
logger.error(f"Failed to register new whatsapp user: {user_whatsapp_number}")
142144
return False
143145

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+
144184
async def send_whatsapp_message(
145185
self,
146186
user_whatsapp_number: str,
@@ -213,52 +253,101 @@ def _get_retention_time_in_seconds(self) -> int:
213253

214254
def _get_whatsapp_markdown(self, msg: str) -> str:
215255
"""Convert conventional markdown syntax to WhatsApp's markdown syntax"""
216-
217256
msg_direction = get_language_direction_from_text(msg)
218257

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_)"""
221271
# Regex details:
222272
# (?<![\*_]) # Negative lookbehind: Ensures that the '*' is not preceded by '*' or '_'
223273
# \* # Matches a literal '*'
224274
# ([^\*_]+?) # 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
226275
# \* # Matches a literal '*'
227276
# (?![\*_]) # 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.
228280
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)
237295
pattern = re.compile(r"(?! )#+ \**_*(.*?)\**_*\n(?!\n)")
296+
text = pattern.sub(r"*_\1_*\n\n", text)
238297

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
246299
pattern = re.compile(r"(?! )#+ \**_*(.*?)\**_*\n\n")
300+
return pattern.sub(r"*_\1_*\n\n", text)
247301

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.
257305
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
260311
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)
262351

263352
def _split_long_messages(self, msg_body: str) -> list[str]:
264353
"""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]:
274363
275364
Returns:
276365
list[str]: A list of message chunks that can be sent separately
277-
278-
Example:
279-
>>> msg = "*_First Header_*\nSome text here...\n\n*_Second Header_*\nMore text..."
280-
>>> _split_long_messages(msg)
281-
['*_First Header_*\nSome text here...', '*_Second Header_*\nMore text...']
282366
"""
283367
# WhatsApp character limit
284368
MAX_LENGTH = 4000

0 commit comments

Comments
 (0)