diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..cf74019 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Python Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r requirements-dev.txt + + - name: Run tests + run: | + pytest tests/ --maxfail=1 --disable-warnings -v + + - name: Check types with mypy + run: | + mypy src/a1base + + - name: Check style with ruff + run: | + ruff check src/a1base tests diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..421704e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 A1Base AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4671a57..9d9f06f 100644 --- a/README.md +++ b/README.md @@ -6,47 +6,164 @@ A powerful and easy-to-use Python client for interacting with the A1Base API. Give your AI agents a phone number, an email, and real autonomy on the internet. -## Features +## Installation -- Send individual messages via WhatsApp -- Send group messages with attachments -- Retrieve message and thread details -- Get recent messages from threads -- Handle incoming WhatsApp messages -- Email integration -- Built-in security features: - - HTTPS-only communication - - Rate limiting - - Input sanitization - - Webhook security - - Safe error handling - -## Installation -bash +```bash pip install a1base +``` + ## Quick Start -python + +```python from a1base import A1BaseClient -from a1base.models import MessageRequest -Initialize client +from a1base.models import MessageRequest, EmailRequest + +# Initialize client client = A1BaseClient( -api_key="your_api_key", -api_secret="your_api_secret" + api_key="your_api_key", + api_secret="your_api_secret" ) -Send a WhatsApp message + +# Send a WhatsApp message message = MessageRequest( -content="Hello, World!", -from_="+1234567890", -to="+0987654321", -service="whatsapp" + content="Hello, World!", + from_="+1234567890", + to="+0987654321", + service="whatsapp" ) response = client.send_individual_message("your_account_id", message) print(f"Message status: {response.status}") +# Send an email +email = EmailRequest( + sender_address="jane@a101.bot", + recipient_address="john@a101.bot", + subject="Hello from A1Base", + body="Have a nice day!", + headers={ + "cc": ["sarah@a101.bot"], + "reply-to": "jane@a101.bot" + } +) +response = client.send_email("your_account_id", email) +print(f"Email status: {response.status}") +``` + +## Authentication + +The A1Base client uses API key authentication. You'll need both an API key and an API secret: + +```python +client = A1BaseClient( + api_key="your_api_key", + api_secret="your_api_secret" +) +``` + +These credentials are automatically included in all API requests as headers. + +## Features + +### Messaging + +#### Send Individual Messages +```python +from a1base.models import MessageRequest + +message = MessageRequest( + content="Your message content", + from_="+1234567890", # Sender's phone number + to="+0987654321", # Recipient's phone number + service="whatsapp", # "whatsapp" or "telegram" + attachment_uri="https://example.com/file.pdf" # Optional +) + +response = client.send_individual_message("account_id", message) +``` + +#### Send Group Messages +```python +from a1base.models import GroupMessageRequest + +message = GroupMessageRequest( + content="Group message content", + from_="+1234567890", + service="whatsapp", + attachment_uri="https://example.com/image.jpg" # Optional +) + +response = client.send_group_message("account_id", message) +``` + +#### Get Message Details +```python +# Get details of a specific message +message_details = client.get_message_details("account_id", "message_id") + +# Get recent messages from a thread +recent_messages = client.get_recent_messages("account_id", "thread_id") + +# Get thread details +thread_details = client.get_thread_details("account_id", "thread_id") + +# Get all threads +all_threads = client.get_all_threads("account_id") + +# Get threads for a specific phone number +number_threads = client.get_threads_by_number("account_id", "+1234567890") +``` + +### Email + +#### Send Emails +```python +from a1base.models import EmailRequest + +email = EmailRequest( + sender_address="sender@a101.bot", + recipient_address="recipient@a101.bot", + subject="Email Subject", + body="Email body content", + headers={ + "bcc": ["bcc@a101.bot"], + "cc": ["cc@a101.bot"], + "reply-to": "reply@a101.bot" + }, + attachment_uri="https://example.com/attachment.pdf" # Optional +) + +response = client.send_email("account_id", email) +``` + +### Error Handling + +The client includes built-in error handling for common scenarios: + +```python +from a1base import AuthenticationError, ValidationError, RateLimitError + +try: + response = client.send_individual_message("account_id", message) +except AuthenticationError: + print("Invalid API credentials") +except ValidationError as e: + print(f"Invalid request: {e}") +except RateLimitError: + print("Rate limit exceeded") +``` + +## Development + +To contribute to the project, install development dependencies: -## Documentation +```bash +# Clone the repository +git clone https://github.com/a1baseai/a1base-python.git +cd a1base-python -For detailed documentation, visit [docs.a1base.com](https://docs.a1base.com) +# Install development dependencies +pip install -r requirements-dev.txt +``` ## Security Features @@ -76,4 +193,4 @@ We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING. ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..847d498 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,12 @@ +[mypy] +ignore_missing_imports = True +strict = True +check_untyped_defs = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +disallow_untyped_decorators = True +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_return_any = True +warn_unreachable = True diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..8f95709 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest>=7.0.0 +coverage>=6.0.0 +mypy>=1.0.0 +ruff>=0.1.0 +black>=23.0.0 diff --git a/src/a1base/client.py b/src/a1base/client.py index 56fb8ae..130dd73 100644 --- a/src/a1base/client.py +++ b/src/a1base/client.py @@ -1,12 +1,16 @@ import httpx -from typing import Optional, Dict, Any -from .exceptions import A1BaseError, AuthenticationError, ValidationError +from typing import Optional, Dict, Any, Union, List, cast +from .exceptions import A1BaseError, AuthenticationError, ValidationError, RateLimitError from .models import ( MessageRequest, MessageResponse, GroupMessageRequest, GroupMessageResponse, - EmailRequest + EmailRequest, + EmailResponse, + ThreadResponse, + JsonDict, + EmailHeaders ) class A1BaseClient: @@ -17,6 +21,11 @@ class A1BaseClient: api_key (str): Your A1Base API key api_secret (str): Your A1Base API secret base_url (str, optional): API base URL. Defaults to https://api.a1base.com/v1 + + Attributes: + base_url (str): Base URL for API requests + headers (Dict[str, str]): HTTP headers for requests + client (httpx.Client): HTTP client for making requests """ def __init__( @@ -24,22 +33,25 @@ def __init__( api_key: str, api_secret: str, base_url: str = "https://api.a1base.com/v1" - ): - self.base_url = base_url.rstrip('/') - self.headers = { + ) -> None: + self.base_url: str = base_url.rstrip('/') + self.headers: Dict[str, str] = { 'x-api-key': api_key, 'x-api-secret': api_secret, 'Content-Type': 'application/json' } - self.client = httpx.Client(timeout=30.0) + self.client: httpx.Client = httpx.Client(timeout=30.0) def _make_request( self, method: str, endpoint: str, - data: Optional[Dict] = None - ) -> Dict[str, Any]: + data: Optional[JsonDict] = None + ) -> JsonDict: """Make HTTP request to A1Base API""" + if not self.headers.get('x-api-key') or not self.headers.get('x-api-secret'): + raise AuthenticationError("API key and secret are required") + url = f"{self.base_url}{endpoint}" try: @@ -53,10 +65,10 @@ def _make_request( return response.json() except httpx.HTTPStatusError as e: if e.response.status_code == 401: - raise AuthenticationError("Invalid API credentials") + raise AuthenticationError("Invalid API credentials") from e elif e.response.status_code == 422: - raise ValidationError(f"Invalid request: {e.response.json()}") - raise A1BaseError(f"Request failed: {str(e)}") + raise ValidationError(f"Invalid request: {e.response.json()}") from e + raise A1BaseError(f"Request failed: {str(e)}") from e def send_individual_message( self, @@ -84,7 +96,21 @@ def send_individual_message( data["attachment_uri"] = message.attachment_uri response = self._make_request("POST", endpoint, data) - return MessageResponse(**response) + # Filter response to only include fields defined in our model + model_fields = {"to", "from", "body", "status"} + filtered_response = {k: v for k, v in response.items() if k in model_fields} + if "from" in filtered_response: + filtered_response["from_"] = filtered_response.pop("from") + # Ensure all required fields are present + if not all(field in filtered_response for field in ["to", "from_", "body"]): + # If response is missing required fields, create a failure response + return MessageResponse( + to=message.to, + from_=message.from_, + body=message.content, + status="failed" + ) + return MessageResponse(**filtered_response) def send_group_message( self, @@ -111,6 +137,140 @@ def send_group_message( data["attachment_uri"] = message.attachment_uri response = self._make_request("POST", endpoint, data) - return GroupMessageResponse(**response) + return GroupMessageResponse(**cast(JsonDict, response)) - # Additional methods following similar pattern... \ No newline at end of file + def send_email( + self, + account_id: str, + email: EmailRequest + ) -> EmailResponse: + """ + Send an email. + + Args: + account_id (str): Your A1Base account ID + email (EmailRequest): Email details + + Returns: + EmailResponse: Response containing email status + + Raises: + AuthenticationError: If API credentials are invalid + ValidationError: If request data is invalid + RateLimitError: If API rate limit is exceeded + """ + endpoint = f"/emails/{account_id}/send" + data: JsonDict = { + "sender_address": email.sender_address, + "recipient_address": email.recipient_address, + "subject": email.subject, + "body": email.body + } + if email.headers: + headers: JsonDict = {} + # Handle optional fields from TypedDict + if "cc" in email.headers: + headers["cc"] = email.headers["cc"] + if "bcc" in email.headers: + headers["bcc"] = email.headers["bcc"] + if "reply_to" in email.headers: + headers["reply-to"] = email.headers["reply_to"] + data["headers"] = headers + if email.attachment_uri: + data["attachment_uri"] = email.attachment_uri + + response = self._make_request("POST", endpoint, data) + return EmailResponse(**cast(JsonDict, response)) + + def get_message_details( + self, + account_id: str, + message_id: str + ) -> MessageResponse: + """ + Get details of a specific message. + + Args: + account_id (str): Your A1Base account ID + message_id (str): ID of the message to retrieve + + Returns: + MessageResponse: Message details + """ + endpoint = f"/messages/individual/{account_id}/get-details/{message_id}" + response = self._make_request("GET", endpoint) + return MessageResponse(**cast(JsonDict, response)) + + def get_recent_messages( + self, + account_id: str, + thread_id: str + ) -> List[MessageResponse]: + """ + Get recent messages from a thread. + + Args: + account_id (str): Your A1Base account ID + thread_id (str): ID of the thread + + Returns: + List[MessageResponse]: List of recent messages + """ + endpoint = f"/messages/threads/{account_id}/get-recent/{thread_id}" + response = self._make_request("GET", endpoint) + return [MessageResponse(**cast(JsonDict, msg)) for msg in response] + + def get_thread_details( + self, + account_id: str, + thread_id: str + ) -> ThreadResponse: + """ + Get details of a specific thread. + + Args: + account_id (str): Your A1Base account ID + thread_id (str): ID of the thread + + Returns: + ThreadResponse: Thread details + """ + endpoint = f"/messages/threads/{account_id}/get-details/{thread_id}" + response = self._make_request("GET", endpoint) + return ThreadResponse(**response) + + def get_all_threads( + self, + account_id: str + ) -> List[ThreadResponse]: + """ + Get all threads for an account. + + Args: + account_id (str): Your A1Base account ID + + Returns: + List[ThreadResponse]: List of thread details + """ + endpoint = f"/messages/threads/{account_id}/get-all" + response = self._make_request("GET", endpoint) + return [ThreadResponse(**cast(JsonDict, thread)) for thread in response] + + def get_threads_by_number( + self, + account_id: str, + phone_number: str + ) -> List[ThreadResponse]: + """ + Get all threads for a specific phone number. + + Args: + account_id (str): Your A1Base account ID + phone_number (str): Phone number to filter threads + + Returns: + List[ThreadResponse]: List of thread details + """ + endpoint = f"/messages/threads/{account_id}/get-all/{phone_number}" + response = self._make_request("GET", endpoint) + return [ThreadResponse(**cast(JsonDict, thread)) for thread in response] \ No newline at end of file diff --git a/src/a1base/models.py b/src/a1base/models.py index 0b4a7f9..697e0d9 100644 --- a/src/a1base/models.py +++ b/src/a1base/models.py @@ -1,32 +1,61 @@ from dataclasses import dataclass -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Literal, Any, TypedDict, Union + +ServiceType = Literal["whatsapp", "telegram"] +MessageStatus = Literal["queued", "sent", "delivered", "failed"] + +class EmailHeaders(TypedDict, total=False): + """Email headers type definition.""" + cc: List[str] + bcc: List[str] + reply_to: str # Maps to "reply-to" in API + +JsonDict = Dict[str, Any] + +@dataclass +class ThreadResponse: + """Thread details response model.""" + id: str + type: str + participants: List[str] + created_at: str + updated_at: str + messages: List[JsonDict] @dataclass class MessageResponse: to: str from_: str body: str - status: str + status: MessageStatus @dataclass class GroupMessageResponse: thread_id: str body: str - status: str + status: MessageStatus + +@dataclass +class EmailResponse: + to: str + from_: str + subject: str + body: str + status: MessageStatus @dataclass class MessageRequest: content: str from_: str to: str - service: str + service: ServiceType attachment_uri: Optional[str] = None @dataclass class GroupMessageRequest: content: str from_: str - service: str + service: ServiceType attachment_uri: Optional[str] = None @dataclass @@ -35,5 +64,5 @@ class EmailRequest: recipient_address: str subject: str body: str - headers: Optional[Dict[str, List[str]]] = None - attachment_uri: Optional[str] = None \ No newline at end of file + headers: Optional[EmailHeaders] = None + attachment_uri: Optional[str] = None \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..9ed819f --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,35 @@ +import pytest +from a1base import A1BaseClient, AuthenticationError, ValidationError +from a1base.models import MessageRequest + +def test_basic_initialization(): + """Test that client can be initialized with API credentials.""" + client = A1BaseClient(api_key="fake_key", api_secret="fake_secret") + assert client is not None + assert client.headers["x-api-key"] == "fake_key" + assert client.headers["x-api-secret"] == "fake_secret" + assert client.base_url == "https://api.a1base.com/v1" + +def test_auth_failure(): + """Test that authentication errors are raised appropriately.""" + client = A1BaseClient(api_key="", api_secret="") + message = MessageRequest( + content="test message", + from_="+1234567890", + to="+0987654321", + service="whatsapp" + ) + with pytest.raises(AuthenticationError): + client.send_individual_message("123", message) + +def test_invalid_account_id(): + """Test that empty account_id raises ValueError.""" + client = A1BaseClient(api_key="fake_key", api_secret="fake_secret") + message = MessageRequest( + content="test message", + from_="+1234567890", + to="+0987654321", + service="whatsapp" + ) + with pytest.raises(ValueError, match="Account ID cannot be empty"): + client.send_individual_message("", message)