Skip to content

Commit c44640f

Browse files
feat: Integrate What's App (#972)
Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com> Co-authored-by: Wendong <w3ndong.fan@gmail.com>
1 parent 2466015 commit c44640f

File tree

3 files changed

+369
-0
lines changed

3 files changed

+369
-0
lines changed

camel/toolkits/whatsapp_toolkit.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
2+
# Licensed under the Apache License, Version 2.0 (the “License”);
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an “AS IS” BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
14+
15+
import os
16+
from typing import Any, Dict, List, Union
17+
18+
import requests
19+
20+
from camel.toolkits import FunctionTool
21+
from camel.toolkits.base import BaseToolkit
22+
from camel.utils.commons import retry_request
23+
24+
25+
class WhatsAppToolkit(BaseToolkit):
26+
r"""A class representing a toolkit for WhatsApp operations.
27+
28+
This toolkit provides methods to interact with the WhatsApp Business API,
29+
allowing users to send messages, retrieve message templates, and get
30+
business profile information.
31+
32+
Attributes:
33+
retries (int): Number of retries for API requests in case of failure.
34+
delay (int): Delay between retries in seconds.
35+
base_url (str): Base URL for the WhatsApp Business API.
36+
version (str): API version.
37+
"""
38+
39+
def __init__(self, retries: int = 3, delay: int = 1):
40+
r"""Initializes the WhatsAppToolkit with the specified number of
41+
retries and delay.
42+
43+
Args:
44+
retries (int): Number of times to retry the request in case of
45+
failure. (default: :obj:`3`)
46+
delay (int): Time in seconds to wait between retries.
47+
(default: :obj:`1`)
48+
"""
49+
self.retries = retries
50+
self.delay = delay
51+
self.base_url = "https://graph.facebook.com"
52+
self.version = "v17.0"
53+
54+
self.access_token = os.environ.get("WHATSAPP_ACCESS_TOKEN", "")
55+
self.phone_number_id = os.environ.get("WHATSAPP_PHONE_NUMBER_ID", "")
56+
57+
if not all([self.access_token, self.phone_number_id]):
58+
raise ValueError(
59+
"WhatsApp API credentials are not set. "
60+
"Please set the WHATSAPP_ACCESS_TOKEN and "
61+
"WHATSAPP_PHONE_NUMBER_ID environment variables."
62+
)
63+
64+
def send_message(
65+
self, to: str, message: str
66+
) -> Union[Dict[str, Any], str]:
67+
r"""Sends a text message to a specified WhatsApp number.
68+
69+
Args:
70+
to (str): The recipient's WhatsApp number in international format.
71+
message (str): The text message to send.
72+
73+
Returns:
74+
Union[Dict[str, Any], str]: A dictionary containing
75+
the API response if successful, or an error message string if
76+
failed.
77+
"""
78+
url = f"{self.base_url}/{self.version}/{self.phone_number_id}/messages"
79+
headers = {
80+
"Authorization": f"Bearer {self.access_token}",
81+
"Content-Type": "application/json",
82+
}
83+
data = {
84+
"messaging_product": "whatsapp",
85+
"to": to,
86+
"type": "text",
87+
"text": {"body": message},
88+
}
89+
90+
try:
91+
response = retry_request(
92+
requests.post,
93+
retries=self.retries,
94+
delay=self.delay,
95+
url=url,
96+
headers=headers,
97+
json=data,
98+
)
99+
response.raise_for_status()
100+
return response.json()
101+
except Exception as e:
102+
return f"Failed to send message: {e!s}"
103+
104+
def get_message_templates(self) -> Union[List[Dict[str, Any]], str]:
105+
r"""Retrieves all message templates for the WhatsApp Business account.
106+
107+
Returns:
108+
Union[List[Dict[str, Any]], str]: A list of dictionaries containing
109+
template information if successful, or an error message string
110+
if failed.
111+
"""
112+
url = (
113+
f"{self.base_url}/{self.version}/{self.phone_number_id}"
114+
"/message_templates"
115+
)
116+
headers = {"Authorization": f"Bearer {self.access_token}"}
117+
118+
try:
119+
response = retry_request(
120+
requests.get,
121+
retries=self.retries,
122+
delay=self.delay,
123+
url=url,
124+
headers=headers,
125+
)
126+
response.raise_for_status()
127+
return response.json().get("data", [])
128+
except Exception as e:
129+
return f"Failed to retrieve message templates: {e!s}"
130+
131+
def get_business_profile(self) -> Union[Dict[str, Any], str]:
132+
r"""Retrieves the WhatsApp Business profile information.
133+
134+
Returns:
135+
Union[Dict[str, Any], str]: A dictionary containing the business
136+
profile information if successful, or an error message string
137+
if failed.
138+
"""
139+
url = (
140+
f"{self.base_url}/{self.version}/{self.phone_number_id}"
141+
"/whatsapp_business_profile"
142+
)
143+
headers = {"Authorization": f"Bearer {self.access_token}"}
144+
params = {
145+
"fields": (
146+
"about,address,description,email,profile_picture_url,"
147+
"websites,vertical"
148+
)
149+
}
150+
151+
try:
152+
response = retry_request(
153+
requests.get,
154+
retries=self.retries,
155+
delay=self.delay,
156+
url=url,
157+
headers=headers,
158+
params=params,
159+
)
160+
response.raise_for_status()
161+
return response.json()
162+
except Exception as e:
163+
return f"Failed to retrieve business profile: {e!s}"
164+
165+
def get_tools(self) -> List[FunctionTool]:
166+
r"""Returns a list of OpenAIFunction objects representing the
167+
functions in the toolkit.
168+
169+
Returns:
170+
List[OpenAIFunction]: A list of OpenAIFunction objects for the
171+
toolkit methods.
172+
"""
173+
return [
174+
FunctionTool(self.send_message),
175+
FunctionTool(self.get_message_templates),
176+
FunctionTool(self.get_business_profile),
177+
]

camel/utils/commons.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,3 +577,32 @@ def handle_http_error(response: requests.Response) -> str:
577577
return "Too Many Requests. You have hit the rate limit."
578578
else:
579579
return "HTTP Error"
580+
581+
582+
def retry_request(
583+
func: Callable, retries: int = 3, delay: int = 1, *args: Any, **kwargs: Any
584+
) -> Any:
585+
r"""Retries a function in case of any errors.
586+
587+
Args:
588+
func (Callable): The function to be retried.
589+
retries (int): Number of retry attempts. (default: :obj:`3`)
590+
delay (int): Delay between retries in seconds. (default: :obj:`1`)
591+
*args: Arguments to pass to the function.
592+
**kwargs: Keyword arguments to pass to the function.
593+
594+
Returns:
595+
Any: The result of the function call if successful.
596+
597+
Raises:
598+
Exception: If all retry attempts fail.
599+
"""
600+
for attempt in range(retries):
601+
try:
602+
return func(*args, **kwargs)
603+
except Exception as e:
604+
print(f"Attempt {attempt + 1}/{retries} failed: {e}")
605+
if attempt < retries - 1:
606+
time.sleep(delay)
607+
else:
608+
raise
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
2+
# Licensed under the Apache License, Version 2.0 (the “License”);
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an “AS IS” BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
14+
import os
15+
from unittest.mock import MagicMock, patch
16+
17+
import pytest
18+
from requests import RequestException
19+
20+
from camel.toolkits.whatsapp_toolkit import WhatsAppToolkit
21+
22+
23+
@pytest.fixture
24+
def whatsapp_toolkit():
25+
# Set environment variables for testing
26+
os.environ['WHATSAPP_ACCESS_TOKEN'] = 'test_token'
27+
os.environ['WHATSAPP_PHONE_NUMBER_ID'] = 'test_phone_number_id'
28+
return WhatsAppToolkit()
29+
30+
31+
def test_init_missing_credentials():
32+
# Test initialization with missing credentials
33+
os.environ.pop('WHATSAPP_ACCESS_TOKEN', None)
34+
os.environ.pop('WHATSAPP_PHONE_NUMBER_ID', None)
35+
36+
with pytest.raises(ValueError):
37+
WhatsAppToolkit()
38+
39+
40+
@patch('requests.post')
41+
def test_send_message_success(mock_post, whatsapp_toolkit):
42+
# Mock successful API response
43+
mock_response = MagicMock()
44+
mock_response.json.return_value = {"message_id": "test_message_id"}
45+
mock_response.raise_for_status.return_value = None
46+
mock_post.return_value = mock_response
47+
48+
result = whatsapp_toolkit.send_message("1234567890", "Test message")
49+
50+
assert result == {"message_id": "test_message_id"}
51+
mock_post.assert_called_once()
52+
53+
54+
@patch('requests.post')
55+
def test_send_message_failure(mock_post, whatsapp_toolkit):
56+
# Mock failed API response
57+
mock_response = MagicMock()
58+
mock_response.raise_for_status.side_effect = Exception("API Error")
59+
mock_post.return_value = mock_response
60+
61+
result = whatsapp_toolkit.send_message("1234567890", "Test message")
62+
63+
assert result == "Failed to send message: API Error"
64+
mock_post.assert_called_once()
65+
66+
67+
@patch('requests.get')
68+
def test_get_message_templates_success(mock_get, whatsapp_toolkit):
69+
# Mock successful API response
70+
mock_response = MagicMock()
71+
mock_response.json.return_value = {
72+
"data": [{"name": "template1"}, {"name": "template2"}]
73+
}
74+
mock_response.raise_for_status.return_value = None
75+
mock_get.return_value = mock_response
76+
77+
result = whatsapp_toolkit.get_message_templates()
78+
79+
assert result == [{"name": "template1"}, {"name": "template2"}]
80+
mock_get.assert_called_once()
81+
82+
83+
@patch('requests.get')
84+
def test_get_message_templates_failure(mock_get, whatsapp_toolkit):
85+
# Mock failed API response
86+
mock_response = MagicMock()
87+
mock_response.raise_for_status.side_effect = Exception("API Error")
88+
mock_response.json.return_value = {
89+
"error": "Failed to retrieve message templates"
90+
}
91+
mock_get.return_value = mock_response
92+
93+
result = whatsapp_toolkit.get_message_templates()
94+
assert result == 'Failed to retrieve message templates: API Error'
95+
mock_get.assert_called_once()
96+
97+
98+
@patch('requests.get')
99+
def test_get_business_profile_success(mock_get, whatsapp_toolkit):
100+
# Mock successful API response
101+
mock_response = MagicMock()
102+
mock_response.json.return_value = {
103+
"name": "Test Business",
104+
"description": "Test Description",
105+
}
106+
mock_response.raise_for_status.return_value = None
107+
mock_get.return_value = mock_response
108+
109+
result = whatsapp_toolkit.get_business_profile()
110+
111+
assert result == {
112+
"name": "Test Business",
113+
"description": "Test Description",
114+
}
115+
mock_get.assert_called_once()
116+
117+
118+
@patch('requests.get')
119+
def test_get_business_profile_failure(mock_get, whatsapp_toolkit):
120+
# Mock failed API response
121+
mock_response = MagicMock()
122+
mock_response.raise_for_status.side_effect = Exception("API Error")
123+
mock_response.json.return_value = {
124+
"error": "Failed to retrieve message templates"
125+
}
126+
mock_get.return_value = mock_response
127+
128+
result = whatsapp_toolkit.get_business_profile()
129+
assert isinstance(result, str)
130+
assert "Failed to retrieve business profile" in result
131+
assert "API Error" in result
132+
mock_get.assert_called_once()
133+
134+
135+
def test_get_tools(whatsapp_toolkit):
136+
tools = whatsapp_toolkit.get_tools()
137+
138+
assert len(tools) == 3
139+
for tool in tools:
140+
assert callable(tool) or hasattr(tool, 'func')
141+
assert callable(tool) or (
142+
hasattr(tool, 'func') and callable(tool.func)
143+
)
144+
145+
146+
@patch('time.sleep')
147+
@patch('requests.post')
148+
def test_retry_mechanism(mock_post, mock_sleep, whatsapp_toolkit):
149+
# Mock failed API responses followed by a success
150+
mock_post.side_effect = [
151+
RequestException("API Error"),
152+
RequestException("API Error"),
153+
MagicMock(
154+
json=lambda: {"message_id": "test_message_id"},
155+
raise_for_status=lambda: None,
156+
),
157+
]
158+
159+
result = whatsapp_toolkit.send_message("1234567890", "Test message")
160+
161+
assert result == {"message_id": "test_message_id"}
162+
assert mock_post.call_count == 3
163+
assert mock_sleep.call_count == 2

0 commit comments

Comments
 (0)