Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,7 @@ secrets.json
.env.local
.env.production
.env.test

# Real API tests — local only
src/test_real.py
test_real.py
50 changes: 41 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,31 +215,49 @@ ccai = CCAI(
api_key="YOUR-API-KEY"
)

# Register a webhook
# Example 1: Register a webhook with auto-generated secret
# If secret is not provided, the server will auto-generate one
config = WebhookConfig(
url="https://your-domain.com/api/ccai-webhook",
events=[WebhookEventType.MESSAGE_SENT, WebhookEventType.MESSAGE_RECEIVED],
secret="your-webhook-secret"
events=[WebhookEventType.MESSAGE_SENT, WebhookEventType.MESSAGE_RECEIVED]
# secret not provided - server will auto-generate and return it
)

webhook = ccai.webhook.register(config)
print(f"Webhook registered with ID: {webhook.id}")
print(f"Auto-generated Secret: {webhook.secretKey}")

# Example 2: Register a webhook with a custom secret
config_custom = WebhookConfig(
url="https://your-domain.com/api/ccai-webhook-v2",
events=[WebhookEventType.MESSAGE_SENT, WebhookEventType.MESSAGE_RECEIVED],
secret="your-custom-secret-key"
)

webhook_custom = ccai.webhook.register(config_custom)
print(f"Webhook with custom secret registered: {webhook_custom.id}")

# List all webhooks
webhooks = ccai.webhook.list()
print(f"Found {len(webhooks)} webhooks")

# Update a webhook
update_data = {
"events": [WebhookEventType.MESSAGE_RECEIVED]
}
updated_webhook = ccai.webhook.update(webhook.id, update_data)
print(f"Webhook updated: {updated_webhook}")
updated_webhook = ccai.webhook.update(webhook.id, {
"url": "https://your-domain.com/api/ccai-webhook-v3"
})
print(f"Webhook updated: {updated_webhook.url}")

# Delete a webhook
result = ccai.webhook.delete(webhook.id)
print(f"Webhook deleted: {result}")

# Verify webhook signature in your handler
def verify_and_handle_webhook(signature, client_id, event_hash, secret):
if ccai.webhook.verify_signature(signature, client_id, event_hash, secret):
print(f"Valid webhook signature verified")
else:
print("Invalid signature")

# Create a webhook handler for web frameworks
def handle_message_sent(event):
print(f"Message sent: {event.message} to {event.to}")
Expand All @@ -256,12 +274,26 @@ webhook_handler = ccai.webhook.create_handler(handlers)

# Use with Flask
from flask import Flask, request, jsonify
import json

app = Flask(__name__)

@app.route('/api/ccai-webhook', methods=['POST'])
def handle_webhook():
payload = request.get_json()
signature = request.headers.get('X-CCAI-Signature', '')
body = request.get_data(as_text=True)
secret = 'your-webhook-secret-key' # Use the secret from webhook registration

# Parse payload to get client_id and event_hash
payload = json.loads(body)
client_id = os.getenv('CCAI_CLIENT_ID')
event_hash = payload.get('eventHash', '')

# Verify signature
if not ccai.webhook.verify_signature(signature, client_id, event_hash, secret):
return jsonify({"error": "Invalid signature"}), 401

# Process webhook
result = webhook_handler(payload)
return jsonify(result)
```
Expand Down
6 changes: 6 additions & 0 deletions pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"include": ["src", "tests"],
"extraPaths": ["src"],
"pythonVersion": "3.10",
"typeCheckingMode": "basic"
}
5 changes: 2 additions & 3 deletions src/ccai_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from .sms.sms import SMS, SMSCampaign, SMSResponse, SMSOptions
from .sms.mms import MMS
from .email_service import Email, EmailAccount, EmailCampaign, EmailResponse, EmailOptions
from .webhook import Webhook, WebhookConfig, WebhookEventType, MessageSentEvent, MessageReceivedEvent
from .webhook import Webhook, WebhookConfig, WebhookEventType, WebhookEvent
from .contact_service import Contact, ContactDoNotTextRequest, ContactDoNotTextResponse

__all__ = [
Expand All @@ -29,8 +29,7 @@
'EmailOptions',
'WebhookConfig',
'WebhookEventType',
'MessageSentEvent',
'MessageReceivedEvent',
'WebhookEvent',
'Contact',
'ContactDoNotTextRequest',
'ContactDoNotTextResponse'
Expand Down
97 changes: 72 additions & 25 deletions src/ccai_python/ccai.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,18 @@
:copyright: 2025 CloudContactAI LLC
"""

from typing import Any, Dict, Optional, TypedDict, cast
from typing import Any, Dict, List, Optional, Union, cast
import os
import requests
from pydantic import BaseModel, Field

from .sms.sms import SMS
from .sms.sms import SMS, Account
from .sms.mms import MMS
from .email_service import Email
from .webhook import Webhook
from .contact_service import Contact


class Account(BaseModel):
"""Account model representing a recipient"""
first_name: str = Field(..., description="Recipient's first name")
last_name: str = Field(..., description="Recipient's last name")
phone: str = Field(..., description="Recipient's phone number in E.164 format")


class CCAIConfig(BaseModel):
"""Configuration for the CCAI client"""
client_id: str = Field(..., description="Client ID for authentication")
Expand All @@ -32,10 +26,18 @@ class CCAIConfig(BaseModel):
default="https://core.cloudcontactai.com/api",
description="Base URL for the API"
)
email_base_url: str = Field(
default="https://email-campaigns.cloudcontactai.com/api/v1",
description="Base URL for the Email API"
)
file_base_url: str = Field(
default="https://files.cloudcontactai.com",
description="Base URL for File processor API"
)
use_test: bool = Field(
default=False,
description="Whether to use test environment URLs"
)


class APIError(Exception):
Expand All @@ -49,30 +51,48 @@ def __init__(self, status_code: int, message: str):
class CCAI:
"""Main client for interacting with the CloudContactAI API"""

# Production URLs
PROD_BASE_URL = "https://core.cloudcontactai.com/api"
PROD_EMAIL_URL = "https://email-campaigns.cloudcontactai.com/api/v1"
PROD_FILES_URL = "https://files.cloudcontactai.com"

# Test environment URLs
TEST_BASE_URL = "https://core-test-cloudcontactai.allcode.com/api"
TEST_EMAIL_URL = "https://email-campaigns-test-cloudcontactai.allcode.com/api/v1"
TEST_FILES_URL = "https://files-test-cloudcontactai.allcode.com"

def __init__(
self,
client_id: str,
api_key: str,
base_url: Optional[str] = None,
email_base_url: Optional[str] = None,
file_base_url: Optional[str] = None,
use_test: bool = False
) -> None:
if not client_id:
raise ValueError("Client ID is required")
if not api_key:
raise ValueError("API Key is required")

if use_test:
default_base_url = "https://core-test-cloudcontactai.allcode.com/api"
file_base_url = "https://files-test-cloudcontactai.allcode.com"
else:
default_base_url = "https://core.cloudcontactai.com/api"
file_base_url = "https://files.cloudcontactai.com"
# Resolve URLs: explicit override > env var > test/prod default
resolved_base = self._resolve_url(
base_url, "CCAI_BASE_URL", self.PROD_BASE_URL, self.TEST_BASE_URL, use_test
)
resolved_email = self._resolve_url(
email_base_url, "CCAI_EMAIL_BASE_URL", self.PROD_EMAIL_URL, self.TEST_EMAIL_URL, use_test
)
resolved_files = self._resolve_url(
file_base_url, "CCAI_FILES_BASE_URL", self.PROD_FILES_URL, self.TEST_FILES_URL, use_test
)

self._config = CCAIConfig(
client_id=client_id,
api_key=api_key,
base_url=base_url or default_base_url,
file_base_url=file_base_url
base_url=resolved_base,
email_base_url=resolved_email,
file_base_url=resolved_files,
use_test=use_test
)

self.sms = SMS(self)
Expand All @@ -81,6 +101,22 @@ def __init__(
self.webhook = Webhook(self)
self.contact = Contact(self)

def _resolve_url(
self,
explicit: Optional[str],
env_var: str,
prod_default: str,
test_default: str,
use_test: bool
) -> str:
"""Resolve URL with priority: explicit > env > prod/test default"""
if explicit:
return explicit
env_val = os.environ.get(env_var)
if env_val:
return env_val
return test_default if use_test else prod_default

@property
def client_id(self) -> str:
return self._config.client_id
Expand All @@ -93,17 +129,25 @@ def api_key(self) -> str:
def base_url(self) -> str:
return self._config.base_url

@property
def email_base_url(self) -> str:
return self._config.email_base_url

@property
def file_base_url(self) -> str:
return self._config.file_base_url

@property
def use_test(self) -> bool:
return self._config.use_test

def request(
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
data: Optional[Union[Dict[str, Any], List[Any]]] = None,
timeout: int = 30
) -> Dict[str, Any]:
) -> Any:
url = f"{self.base_url}{endpoint}"
headers = {
"Authorization": f"Bearer {self.api_key}",
Expand Down Expand Up @@ -132,24 +176,25 @@ def request(
raise
except requests.RequestException as e:
raise APIError(0, f"Network error: {str(e)}")

def custom_request(
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
base_url: Optional[str] = None,
extra_headers: Optional[Dict[str, str]] = None,
timeout: int = 30
) -> Dict[str, Any]:
"""Make a custom request to a different base URL"""
url = f"{base_url or self.base_url}{endpoint}"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"Accept": "*/*",
"AccountId": str(self.client_id),
"ClientId": str(self.client_id)
"Accept": "*/*"
}
if extra_headers:
headers.update(extra_headers)

try:
response = requests.request(
Expand Down Expand Up @@ -184,18 +229,20 @@ def main():
parser.add_argument("--first_name", default="John", help="Recipient's first name")
parser.add_argument("--last_name", default="Doe", help="Recipient's last name")
parser.add_argument("--message", required=True, help="The message to send")
parser.add_argument("--title", default="CLI Campaign", help="Campaign title")

args = parser.parse_args()

ccai = CCAI(client_id=args.client_id, api_key=args.api_key)

account = Account(
from ccai_python.sms.sms import Account as SMSAccount
account = SMSAccount(
first_name=args.first_name,
last_name=args.last_name,
phone=args.phone
)

response = ccai.sms.send(account=account, message=args.message)
response = ccai.sms.send(accounts=[account], message=args.message, title=args.title)

print("✅ SMS Sent!")
print("📨 Response:", response)
Expand Down
4 changes: 2 additions & 2 deletions src/ccai_python/contact_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))

ccai = CCAI(
client_id=os.getenv('CCAI_CLIENT_ID'),
api_key=os.getenv('CCAI_API_KEY')
client_id=os.environ['CCAI_CLIENT_ID'] ,
api_key=os.environ['CCAI_API_KEY']
)

# Test set_do_not_text with phone number
Expand Down
10 changes: 5 additions & 5 deletions src/ccai_python/email_send.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
from ccai_python import CCAI, EmailAccount

ccai = CCAI(
client_id=os.getenv('CCAI_CLIENT_ID'),
api_key=os.getenv('CCAI_API_KEY'),
client_id=os.environ['CCAI_CLIENT_ID'],
api_key=os.environ['CCAI_API_KEY'],
use_test=True
)

response = ccai.email.send_single(
first_name=os.getenv('TEST_FIRST_NAME'),
last_name=os.getenv('TEST_LAST_NAME'),
email=os.getenv('TEST_EMAIL'),
first_name=os.environ['TEST_FIRST_NAME'],
last_name=os.environ['TEST_LAST_NAME'],
email=os.environ['TEST_EMAIL'],
subject="Test Email from Python",
message="<h1>Hello Andreas!</h1><p>This is a test email from the Python CCAI client.</p>",
sender_email="noreply@cloudcontactai.com",
Expand Down
Loading