-
Notifications
You must be signed in to change notification settings - Fork 3
feat(voice): full ConversationRelay TwiML customization for inbound and outbound #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2b240e3
23f67a6
7addb9e
4f886d1
d2d2572
0ae56f9
6e601cf
0300a56
7f8d4b0
7eab73a
787c8ec
044d402
47f0265
ba021a5
40b16ab
a937b8b
fc75195
f078b96
93e3ce3
16b16d4
779c437
547ef84
0fd34f2
99c9046
c53bbea
d7eb8ca
1bef7f5
8c80073
27ff56d
1e0f97c
53bf02e
a807509
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,8 +41,8 @@ | |
| ) | ||
| from tac.models.session import ConversationSession | ||
| from tac.models.tac import TACMemoryResponse | ||
| from tac.models.voice import TwiMLOptions | ||
| from tac.server import TACFastAPIServer | ||
| from tac.server.config import TACServerConfig | ||
|
|
||
| load_dotenv() | ||
| set_tracing_disabled(True) | ||
|
|
@@ -91,7 +91,17 @@ def parse_args() -> argparse.Namespace: | |
| if tac.config.whatsapp_number | ||
| else None | ||
| ) | ||
| voice_channel = VoiceChannel(tac, config=VoiceChannelConfig(memory_mode="always")) | ||
| voice_channel = VoiceChannel( | ||
| tac, | ||
| config=VoiceChannelConfig( | ||
| memory_mode="once", | ||
| # Channel-wide TwiML applied to every call (inbound + outbound). | ||
| default_twiml_options=TwiMLOptions( | ||
| voice="en-US-Journey-D", | ||
| welcome_greeting="Hi! How can I help?", | ||
| ), | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| async def handle_message_ready( | ||
|
|
@@ -162,18 +172,11 @@ async def initiate_outbound(args: argparse.Namespace) -> None: | |
| print("\nWaiting for replies... (Ctrl+C to exit)\n") | ||
|
|
||
| elif args.channel == "voice": | ||
| server_config = TACServerConfig.from_env() | ||
| public_domain = server_config.public_domain | ||
| if not public_domain: | ||
| print("TWILIO_VOICE_PUBLIC_DOMAIN is required for voice calls.") | ||
| sys.exit(1) | ||
|
|
||
| voice_result = await voice_channel.initiate_outbound_conversation( | ||
| InitiateVoiceConversationOptions( | ||
| to=args.to, | ||
| websocket_url=f"wss://{public_domain}/ws", | ||
| welcome_greeting=args.welcome_greeting, | ||
| action_url=f"https://{public_domain}/conversation-relay-callback", | ||
| # Per-call TwiML overrides for this outbound call. Overrides channel defaults | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. websocket_url and action_url is constructed by the channel so no need to construct manually and pass in |
||
| twiml_options=TwiMLOptions(welcome_greeting=args.welcome_greeting), | ||
| ) | ||
| ) | ||
| print(f"Call placed to {args.to} (SID: {voice_result.call_sid})") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| """ | ||
| Feature: ConversationRelay TwiML customization | ||
|
|
||
| Two layers (highest precedence first): | ||
|
|
||
| 1. Per-call customizer — register on the channel via | ||
| ``voice_channel.on_inbound_call_twiml(...)``. Async callable that | ||
| receives a TwiMLRequest (parsed Twilio webhook fields: From, To, | ||
| CallerCountry, …). Inbound only. For outbound, pass per-call | ||
| TwiMLOptions on InitiateVoiceConversationOptions. | ||
| 2. ``VoiceChannelConfig.default_twiml_options`` — static TwiMLOptions | ||
| applied to every call (inbound and outbound). | ||
|
|
||
| Layers merge per-field: the customizer overrides only the fields it | ||
| explicitly sets; everything else falls through to ``default_twiml_options`` | ||
| and then to TAC defaults (websocket URL, action URL, conversation_configuration). | ||
|
|
||
| This example shows both layers together — channel-wide defaults plus a | ||
| country-based customizer that overrides language/voice/greeting for | ||
| specific callers. | ||
| """ | ||
|
|
||
| from dotenv import load_dotenv | ||
|
|
||
| from tac import TAC, TACConfig | ||
| from tac.channels.voice import VoiceChannel, VoiceChannelConfig | ||
| from tac.models.session import ConversationSession | ||
| from tac.models.tac import TACMemoryResponse | ||
| from tac.models.voice import TwiMLOptions, TwiMLRequest | ||
| from tac.server import TACFastAPIServer | ||
|
|
||
| load_dotenv() | ||
|
|
||
| tac = TAC(config=TACConfig.from_env()) | ||
|
|
||
|
|
||
| async def handle_message_ready( | ||
| user_message: str, | ||
| context: ConversationSession, | ||
| memory_response: TACMemoryResponse | None, | ||
| ) -> str: | ||
| return f"You said: {user_message}" | ||
|
|
||
|
|
||
| tac.on_message_ready(handle_message_ready) | ||
|
|
||
|
|
||
| async def customize_twiml(req: TwiMLRequest) -> TwiMLOptions: | ||
| """Per-call overrides for inbound calls. Only the fields you set here | ||
| override the channel default; the rest fall through.""" | ||
| if req.caller_country == "MX": | ||
| return TwiMLOptions( | ||
| language="es-MX", | ||
| welcome_greeting="¡Hola! ¿En qué puedo ayudarte?", | ||
| ) | ||
| if req.caller_country == "FR": | ||
| return TwiMLOptions( | ||
| language="fr-FR", | ||
| welcome_greeting="Bonjour ! Comment puis-je vous aider ?", | ||
| ) | ||
| return TwiMLOptions() # fall through to default_twiml_options | ||
|
|
||
|
|
||
| voice_channel = VoiceChannel( | ||
| tac, | ||
| config=VoiceChannelConfig( | ||
| # Channel-wide defaults — apply to every call (inbound + outbound). | ||
| default_twiml_options=TwiMLOptions( | ||
| welcome_greeting="Hello! This is a default greeting.", | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. welcome_greeting set here instead of on the server |
||
| interruptible="speech", | ||
| # Escape hatch for ConversationRelay attributes TAC hasn't typed yet: | ||
| # extra={"newRelayAttribute": "value"}, | ||
| ), | ||
| ), | ||
| ) | ||
| # Register the per-call inbound customizer. | ||
| voice_channel.on_inbound_call_twiml(customize_twiml) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Putting this callback here, in anticipation of more crelay callbacks being added to the voice channel in the near future, like: Idea being omnichanell / TAC wide callbacks are registered directly on |
||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| server = TACFastAPIServer(tac=tac, voice_channel=voice_channel) | ||
| server.start() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,22 @@ | ||
| """Voice channel for handling voice-based conversations.""" | ||
|
|
||
| from tac.channels.voice.channel import VoiceChannel | ||
| from tac.channels.voice.config import VoiceChannelConfig | ||
| from tac.channels.voice.config import InboundCallTwiMLHandler, VoiceChannelConfig | ||
| from tac.channels.voice.twiml import generate_twiml | ||
| from tac.models.voice import ( | ||
| InterruptMode, | ||
| LanguageConfig, | ||
| TwiMLOptions, | ||
| TwiMLRequest, | ||
| ) | ||
|
|
||
| __all__ = ["VoiceChannel", "VoiceChannelConfig", "generate_twiml"] | ||
| __all__ = [ | ||
| "InboundCallTwiMLHandler", | ||
| "InterruptMode", | ||
| "LanguageConfig", | ||
| "TwiMLOptions", | ||
| "TwiMLRequest", | ||
| "VoiceChannel", | ||
| "VoiceChannelConfig", | ||
| "generate_twiml", | ||
| ] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ryanrishi
Added
extra="forbid"here to throw an error for users that upgrade. Originally had these fields deprecated throughout but ended up just removing them after our stand up discussion a few days ago