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
18 changes: 18 additions & 0 deletions getting_started/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,24 @@ See `examples/.env.example` for all available configuration options. Key variabl
- `TWILIO_WHATSAPP_NUMBER`: WhatsApp-enabled phone number in format `whatsapp:+1234567890` (required for `features/whatsapp.py`)
- `TWILIO_CONVERSATIONS_SERVICE_SID`: Conversations Service SID (required for Chat channel examples)

## Make an outbound call
```python
from tac import TAC, TACConfig

tac = TAC(config=TACConfig.from_env())
voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(memory_mode="always"))

tac_voice_domain = os.getenv("TAC_SERVER_DOMAIN", "")

opts = InitiateVoiceConversationOptions(
to="+4498767667",
websocket_url=f"wss://{tac_voice_domain}/ws",
welcome_greeting="Hello",
action_url=f"https://{tac_voice_domain}/conversation-relay-callback",
)
voice_channel.initiate_outbound_conversation(opts)
```

## Next Steps

- Start with `examples/overview.py` to learn the core memory injection pattern
Expand Down
54 changes: 54 additions & 0 deletions src/tac/channels/voice/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from typing import Literal
Comment on lines 8 to +9
from twilio.rest import Client

from pydantic import ValidationError
Expand Down Expand Up @@ -142,6 +143,52 @@ def _caller_address(setup_msg: SetupMessage) -> str | None:
return setup_msg.to_number
return setup_msg.from_number

async def _fix_outbound_participant_roles(
self,
conv_id: str,
participants: list[Any],
agent_phone: str,
) -> list[Any]:
"""Correct participant roles for outbound calls.

ConversationRelay assigns participant types based on call leg direction,
which inverts CUSTOMER/AI_AGENT for outbound calls (the agent's number
becomes CUSTOMER). Fix by comparing each participant's VOICE address to
the agent's phone number and updating any mismatched types via CO API.
"""
co_client = self.tac.conversation_orchestrator_client
if co_client is None:
return participants
fixed = []
for p in participants:
voice_addr = next(
(a.address for a in (p.addresses or []) if a.channel == "VOICE"),
None,
)
if voice_addr is None:
fixed.append(p)
continue
expected_type: Literal["AI_AGENT", "CUSTOMER"] = (
"AI_AGENT" if voice_addr == agent_phone else "CUSTOMER"
)
Comment on lines +171 to +173
if p.type != expected_type:
self.logger.debug(
"Fixing participant role for outbound call",
participant_id=p.id,
from_type=p.type,
to_type=expected_type,
)
updated = await co_client.update_participant(
conversation_id=conv_id,
participant_id=p.id,
participant_type=expected_type,
addresses=p.addresses or [],
)
fixed.append(updated)
else:
fixed.append(p)
return fixed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need this after voice team fixes this fix on the conversation relay side. Let's wait and see till then


def _get_twilio_client(self) -> Client:
if self._twilio_client is None:
from twilio.rest import Client
Expand Down Expand Up @@ -393,6 +440,13 @@ async def _initialize_conversation(

participants = await conversation_orchestrator_client.list_participants(conv_id)

if setup_msg.direction and setup_msg.direction.upper() == "OUTBOUND-API":
agent_phone = setup_msg.from_number
if agent_phone:
participants = await self._fix_outbound_participant_roles(
conv_id, participants, agent_phone
)

customer_participant = next(
(p for p in participants if p.type == "CUSTOMER"),
None,
Expand Down
2 changes: 2 additions & 0 deletions src/tac/server/fastapi_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from __future__ import annotations

import asyncio
import json
import os
from typing import TYPE_CHECKING, Any

from tac.channels.base import BaseChannel
Expand Down
135 changes: 135 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,141 @@ def test_connect_action_uses_cleanup_url_when_no_handoff_flow(self) -> None:
assert 'action="https://test.ngrok.io/conversation-relay-callback"' in resp.text


class TestConversationRelayCallback:
"""Test the /conversation-relay-callback route."""

def _build_server_and_channel(self): # type: ignore[no-untyped-def]
from tac.channels.voice import VoiceChannel
from tac.server import TACFastAPIServer

tac = TAC(get_test_config())
vc = VoiceChannel(tac)
server = TACFastAPIServer(
tac=tac,
config=TACServerConfig(public_domain="test.ngrok.io"),
voice_channel=vc,
)
return server.app, vc

@pytest.mark.asyncio
async def test_callback_without_handoff_returns_200(self) -> None:
"""Callback with no HandoffData returns 200 plain text and calls the channel handler."""
from unittest.mock import AsyncMock, patch

from httpx import ASGITransport, AsyncClient

app, vc = self._build_server_and_channel()

with patch.object(vc, "handle_conversation_relay_callback", new_callable=AsyncMock) as mock:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/conversation-relay-callback",
data={"CallSid": "CA123", "AccountSid": "ACtest123", "CallStatus": "completed"},
)

assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/plain")
mock.assert_called_once_with(
{"CallSid": "CA123", "AccountSid": "ACtest123", "CallStatus": "completed"}
)

@pytest.mark.asyncio
async def test_callback_with_handoff_but_no_workflow_sid_returns_200(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""HandoffData present but no TWILIO_TASKROUTER_WORKFLOW_SID → plain 200, no TwiML."""
from unittest.mock import AsyncMock, patch

from httpx import ASGITransport, AsyncClient

monkeypatch.delenv("TWILIO_TASKROUTER_WORKFLOW_SID", raising=False)

app, vc = self._build_server_and_channel()

with patch.object(vc, "handle_conversation_relay_callback", new_callable=AsyncMock):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/conversation-relay-callback",
data={
"CallSid": "CA123",
"AccountSid": "ACtest123",
"CallStatus": "completed",
"HandoffData": '{"someKey": "someValue"}',
},
)

assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/plain")
assert "<Enqueue" not in resp.text

@pytest.mark.asyncio
async def test_callback_with_handoff_and_workflow_sid_returns_enqueue_twiml(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""HandoffData + TWILIO_TASKROUTER_WORKFLOW_SID → TwiML <Enqueue> response."""
from unittest.mock import AsyncMock, patch

from httpx import ASGITransport, AsyncClient

monkeypatch.setenv("TWILIO_TASKROUTER_WORKFLOW_SID", "WF123")

app, vc = self._build_server_and_channel()

handoff_data = '{"conversationId": "CH_test", "profileId": "PR_test"}'

with patch.object(vc, "handle_conversation_relay_callback", new_callable=AsyncMock):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/conversation-relay-callback",
data={
"CallSid": "CA123",
"AccountSid": "ACtest123",
"CallStatus": "completed",
"HandoffData": handoff_data,
},
)

assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/xml")
assert '<Enqueue workflowSid="WF123">' in resp.text

@pytest.mark.asyncio
async def test_callback_enqueue_twiml_wraps_handoff_data_in_task(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""<Task> element contains {\"handoffData\": <original HandoffData value>}."""
import json
from unittest.mock import AsyncMock, patch

from httpx import ASGITransport, AsyncClient

monkeypatch.setenv("TWILIO_TASKROUTER_WORKFLOW_SID", "WF456")

app, vc = self._build_server_and_channel()

handoff_data = '{"conversationId": "CH_abc", "profileId": "PR_xyz"}'

with patch.object(vc, "handle_conversation_relay_callback", new_callable=AsyncMock):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/conversation-relay-callback",
data={
"CallSid": "CA456",
"AccountSid": "ACtest123",
"CallStatus": "completed",
"HandoffData": handoff_data,
},
)

assert resp.status_code == 200
expected_task = json.dumps({"handoffData": handoff_data})
assert f"<Task>{expected_task}</Task>" in resp.text


class TestSignatureValidation:
"""Test that webhook signature validation is enforced on all TAC routes."""

Expand Down
Loading