Skip to content

Commit

Permalink
feat: Integrate What's App (#972)
Browse files Browse the repository at this point in the history
Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com>
Co-authored-by: Wendong <w3ndong.fan@gmail.com>
  • Loading branch information
3 people authored Oct 17, 2024
1 parent 2466015 commit c44640f
Show file tree
Hide file tree
Showing 3 changed files with 369 additions and 0 deletions.
177 changes: 177 additions & 0 deletions camel/toolkits/whatsapp_toolkit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========

import os
from typing import Any, Dict, List, Union

import requests

from camel.toolkits import FunctionTool
from camel.toolkits.base import BaseToolkit
from camel.utils.commons import retry_request


class WhatsAppToolkit(BaseToolkit):
r"""A class representing a toolkit for WhatsApp operations.
This toolkit provides methods to interact with the WhatsApp Business API,
allowing users to send messages, retrieve message templates, and get
business profile information.
Attributes:
retries (int): Number of retries for API requests in case of failure.
delay (int): Delay between retries in seconds.
base_url (str): Base URL for the WhatsApp Business API.
version (str): API version.
"""

def __init__(self, retries: int = 3, delay: int = 1):
r"""Initializes the WhatsAppToolkit with the specified number of
retries and delay.
Args:
retries (int): Number of times to retry the request in case of
failure. (default: :obj:`3`)
delay (int): Time in seconds to wait between retries.
(default: :obj:`1`)
"""
self.retries = retries
self.delay = delay
self.base_url = "https://graph.facebook.com"
self.version = "v17.0"

self.access_token = os.environ.get("WHATSAPP_ACCESS_TOKEN", "")
self.phone_number_id = os.environ.get("WHATSAPP_PHONE_NUMBER_ID", "")

if not all([self.access_token, self.phone_number_id]):
raise ValueError(
"WhatsApp API credentials are not set. "
"Please set the WHATSAPP_ACCESS_TOKEN and "
"WHATSAPP_PHONE_NUMBER_ID environment variables."
)

def send_message(
self, to: str, message: str
) -> Union[Dict[str, Any], str]:
r"""Sends a text message to a specified WhatsApp number.
Args:
to (str): The recipient's WhatsApp number in international format.
message (str): The text message to send.
Returns:
Union[Dict[str, Any], str]: A dictionary containing
the API response if successful, or an error message string if
failed.
"""
url = f"{self.base_url}/{self.version}/{self.phone_number_id}/messages"
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
data = {
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": message},
}

try:
response = retry_request(
requests.post,
retries=self.retries,
delay=self.delay,
url=url,
headers=headers,
json=data,
)
response.raise_for_status()
return response.json()
except Exception as e:
return f"Failed to send message: {e!s}"

def get_message_templates(self) -> Union[List[Dict[str, Any]], str]:
r"""Retrieves all message templates for the WhatsApp Business account.
Returns:
Union[List[Dict[str, Any]], str]: A list of dictionaries containing
template information if successful, or an error message string
if failed.
"""
url = (
f"{self.base_url}/{self.version}/{self.phone_number_id}"
"/message_templates"
)
headers = {"Authorization": f"Bearer {self.access_token}"}

try:
response = retry_request(
requests.get,
retries=self.retries,
delay=self.delay,
url=url,
headers=headers,
)
response.raise_for_status()
return response.json().get("data", [])
except Exception as e:
return f"Failed to retrieve message templates: {e!s}"

def get_business_profile(self) -> Union[Dict[str, Any], str]:
r"""Retrieves the WhatsApp Business profile information.
Returns:
Union[Dict[str, Any], str]: A dictionary containing the business
profile information if successful, or an error message string
if failed.
"""
url = (
f"{self.base_url}/{self.version}/{self.phone_number_id}"
"/whatsapp_business_profile"
)
headers = {"Authorization": f"Bearer {self.access_token}"}
params = {
"fields": (
"about,address,description,email,profile_picture_url,"
"websites,vertical"
)
}

try:
response = retry_request(
requests.get,
retries=self.retries,
delay=self.delay,
url=url,
headers=headers,
params=params,
)
response.raise_for_status()
return response.json()
except Exception as e:
return f"Failed to retrieve business profile: {e!s}"

def get_tools(self) -> List[FunctionTool]:
r"""Returns a list of OpenAIFunction objects representing the
functions in the toolkit.
Returns:
List[OpenAIFunction]: A list of OpenAIFunction objects for the
toolkit methods.
"""
return [
FunctionTool(self.send_message),
FunctionTool(self.get_message_templates),
FunctionTool(self.get_business_profile),
]
29 changes: 29 additions & 0 deletions camel/utils/commons.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,3 +577,32 @@ def handle_http_error(response: requests.Response) -> str:
return "Too Many Requests. You have hit the rate limit."
else:
return "HTTP Error"


def retry_request(
func: Callable, retries: int = 3, delay: int = 1, *args: Any, **kwargs: Any
) -> Any:
r"""Retries a function in case of any errors.
Args:
func (Callable): The function to be retried.
retries (int): Number of retry attempts. (default: :obj:`3`)
delay (int): Delay between retries in seconds. (default: :obj:`1`)
*args: Arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
Any: The result of the function call if successful.
Raises:
Exception: If all retry attempts fail.
"""
for attempt in range(retries):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt + 1}/{retries} failed: {e}")
if attempt < retries - 1:
time.sleep(delay)
else:
raise
163 changes: 163 additions & 0 deletions test/toolkits/test_whatsapp_function.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
# Licensed under the Apache License, Version 2.0 (the “License”);
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an “AS IS” BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. ===========
import os
from unittest.mock import MagicMock, patch

import pytest
from requests import RequestException

from camel.toolkits.whatsapp_toolkit import WhatsAppToolkit


@pytest.fixture
def whatsapp_toolkit():
# Set environment variables for testing
os.environ['WHATSAPP_ACCESS_TOKEN'] = 'test_token'
os.environ['WHATSAPP_PHONE_NUMBER_ID'] = 'test_phone_number_id'
return WhatsAppToolkit()


def test_init_missing_credentials():
# Test initialization with missing credentials
os.environ.pop('WHATSAPP_ACCESS_TOKEN', None)
os.environ.pop('WHATSAPP_PHONE_NUMBER_ID', None)

with pytest.raises(ValueError):
WhatsAppToolkit()


@patch('requests.post')
def test_send_message_success(mock_post, whatsapp_toolkit):
# Mock successful API response
mock_response = MagicMock()
mock_response.json.return_value = {"message_id": "test_message_id"}
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response

result = whatsapp_toolkit.send_message("1234567890", "Test message")

assert result == {"message_id": "test_message_id"}
mock_post.assert_called_once()


@patch('requests.post')
def test_send_message_failure(mock_post, whatsapp_toolkit):
# Mock failed API response
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("API Error")
mock_post.return_value = mock_response

result = whatsapp_toolkit.send_message("1234567890", "Test message")

assert result == "Failed to send message: API Error"
mock_post.assert_called_once()


@patch('requests.get')
def test_get_message_templates_success(mock_get, whatsapp_toolkit):
# Mock successful API response
mock_response = MagicMock()
mock_response.json.return_value = {
"data": [{"name": "template1"}, {"name": "template2"}]
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response

result = whatsapp_toolkit.get_message_templates()

assert result == [{"name": "template1"}, {"name": "template2"}]
mock_get.assert_called_once()


@patch('requests.get')
def test_get_message_templates_failure(mock_get, whatsapp_toolkit):
# Mock failed API response
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("API Error")
mock_response.json.return_value = {
"error": "Failed to retrieve message templates"
}
mock_get.return_value = mock_response

result = whatsapp_toolkit.get_message_templates()
assert result == 'Failed to retrieve message templates: API Error'
mock_get.assert_called_once()


@patch('requests.get')
def test_get_business_profile_success(mock_get, whatsapp_toolkit):
# Mock successful API response
mock_response = MagicMock()
mock_response.json.return_value = {
"name": "Test Business",
"description": "Test Description",
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response

result = whatsapp_toolkit.get_business_profile()

assert result == {
"name": "Test Business",
"description": "Test Description",
}
mock_get.assert_called_once()


@patch('requests.get')
def test_get_business_profile_failure(mock_get, whatsapp_toolkit):
# Mock failed API response
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("API Error")
mock_response.json.return_value = {
"error": "Failed to retrieve message templates"
}
mock_get.return_value = mock_response

result = whatsapp_toolkit.get_business_profile()
assert isinstance(result, str)
assert "Failed to retrieve business profile" in result
assert "API Error" in result
mock_get.assert_called_once()


def test_get_tools(whatsapp_toolkit):
tools = whatsapp_toolkit.get_tools()

assert len(tools) == 3
for tool in tools:
assert callable(tool) or hasattr(tool, 'func')
assert callable(tool) or (
hasattr(tool, 'func') and callable(tool.func)
)


@patch('time.sleep')
@patch('requests.post')
def test_retry_mechanism(mock_post, mock_sleep, whatsapp_toolkit):
# Mock failed API responses followed by a success
mock_post.side_effect = [
RequestException("API Error"),
RequestException("API Error"),
MagicMock(
json=lambda: {"message_id": "test_message_id"},
raise_for_status=lambda: None,
),
]

result = whatsapp_toolkit.send_message("1234567890", "Test message")

assert result == {"message_id": "test_message_id"}
assert mock_post.call_count == 3
assert mock_sleep.call_count == 2

0 comments on commit c44640f

Please sign in to comment.