Skip to content
Merged
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
12 changes: 6 additions & 6 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -2053,24 +2053,24 @@ result.pay(
Start call tapping/monitoring.

**Parameters:**
- `uri` (str): URI to send tapped audio to
- `uri` (str): URI to send tapped audio to — `rtp://IP:port`, `ws://example.com`, or `wss://example.com`
- `control_id` (Optional[str]): Unique identifier for this tap
- `direction` (str): Tap direction: "both", "inbound", "outbound" (default: "both")
- `codec` (str): Audio codec: "PCMU", "PCMA", "G722" (default: "PCMU")
- `direction` (str): Tap direction: "speak", "listen", "both" (default: "both")
- `codec` (str): Audio codec: "PCMU" or "PCMA" (default: "PCMU")
- `rtp_ptime` (int): RTP packet time in milliseconds (default: 20)
- `status_url` (Optional[str]): Status webhook URL

**Usage:**
```python
# Basic call tapping
result.tap("sip:monitor@company.com")
result.tap("wss://monitor.company.com/tap")

# Tap with specific settings
result.tap(
uri="sip:quality@company.com",
uri="rtp://192.168.1.100:5004",
control_id="quality_monitor_001",
direction="both",
codec="G722"
codec="PCMA"
)
```

Expand Down
4 changes: 2 additions & 2 deletions docs/swaig_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,9 @@ result.tap(
- `control_id`: Identifier for this tap to use with stop_tap (optional, auto-generated if not provided)

**Audio Configuration:**
- `direction`: Audio direction to tap (default: "both")
- `direction`: Audio direction to tap (default: "both"; always sent — the underlying SWML verb defaults to "speak" when omitted)
- `"speak"`: What party says
- `"hear"`: What party hears
- `"listen"`: What party hears
- `"both"`: What party hears and says
- `codec`: Codec for tap stream - "PCMU" or "PCMA" (default: "PCMU")
- `rtp_ptime`: RTP packetization time in milliseconds (default: 20)
Expand Down
8 changes: 6 additions & 2 deletions rest/docs/calling.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,13 @@ client.calling.ai_stop(call_id, control_id="ai-1")

## Live Transcribe & Translate

`action` is an object keyed by the phase (`start`/`summarize`), with the phase's
parameters inside it — or the literal string `"stop"`:

```python
client.calling.live_transcribe(call_id, action="start", lang="en")
client.calling.live_translate(call_id, action="start", from_lang="en", to_lang="es")
client.calling.live_transcribe(call_id, action={"start": {"lang": "en", "webhook": "https://example.com/transcripts"}})
client.calling.live_transcribe(call_id, action="stop")
client.calling.live_translate(call_id, action={"start": {"from_lang": "en", "to_lang": "es"}})
```

## Fax
Expand Down
7 changes: 6 additions & 1 deletion signalwire/signalwire/ai_chat/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,8 +540,13 @@ async def proxy(request: Request) -> Response:
return JSONResponse({"status": "ended"}, headers=cors)

if method == "create_conversation":
# prepare() decides whether a timeout applies; dropping it here
# would report one number to the browser below while the
# service quietly keeps its own default.
info = await self._client.create_conversation(
params["id"], config_url=params["config_url"]
params["id"],
config_url=params["config_url"],
timeout=params.get("conversation_timeout"),
)
if minted:
cors["X-Chat-Handle"] = minted
Expand Down
21 changes: 13 additions & 8 deletions signalwire/signalwire/core/function_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ def execute_swml(
"""
# Detect input type and normalize to appropriate format
if isinstance(swml_content, str):
# Raw SWML string - parse to dict so we can add transfer key if needed
# Raw SWML string - parse to dict so the action carries a document
try:
import json

Expand All @@ -496,11 +496,15 @@ def execute_swml(
else:
raise TypeError("swml_content must be string, dict, or SWML object")

action = swml_data
# transfer rides BESIDE the SWML document, not inside it — the same
# shape connect() and swml_transfer() emit. Inside the document it is
# not a SWML key and the call never exits the agent.
action: dict[str, Any] = {"SWML": swml_data}
if transfer:
action["transfer"] = "true"

return self.add_action("SWML", action)
self.action.append(action)
return self

def hangup(self) -> "FunctionResult":
"""
Expand Down Expand Up @@ -1362,7 +1366,7 @@ def tap(
self,
uri: str,
control_id: str | None = None,
direction: Literal["speak", "hear", "both"] = "both",
direction: Literal["speak", "listen", "both"] = "both",
codec: Literal["PCMU", "PCMA"] = "PCMU",
rtp_ptime: int = 20,
status_url: str | None = None,
Expand All @@ -1380,7 +1384,7 @@ def tap(
Default is generated and stored in tap_control_id variable
direction: Direction of audio to tap (default: "both")
"speak" = what party says
"hear" = what party hears
"listen" = what party hears
"both" = what party hears and says
codec: Codec for tap media stream - "PCMU" or "PCMA" (default: "PCMU")
rtp_ptime: Packetization time in milliseconds for RTP (default: 20)
Expand All @@ -1393,7 +1397,7 @@ def tap(
ValueError: If direction or codec values are invalid
"""
# Validate direction parameter
valid_directions = ["speak", "hear", "both"]
valid_directions = ["speak", "listen", "both"]
if direction not in valid_directions:
raise ValueError(f"direction must be one of {valid_directions}")

Expand All @@ -1412,8 +1416,9 @@ def tap(
# Add optional parameters if they differ from defaults
if control_id:
tap_params["control_id"] = control_id
if direction != "both":
tap_params["direction"] = direction
# Always sent: the verb's own default is "speak", not this helper's
# "both", so omitting it would tap less than the caller asked for.
tap_params["direction"] = direction
if codec != "PCMU":
tap_params["codec"] = codec
if rtp_ptime != 20:
Expand Down
2 changes: 2 additions & 0 deletions signalwire/signalwire/search/search_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
See LICENSE file in the project root for full license information.
"""

from __future__ import annotations

import hashlib
import json
from collections.abc import Awaitable, Callable
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/ai_chat/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,3 +608,40 @@ async def test_start_then_reload_replays_the_same_conversation(
assert "messages" in replay.json()
# and it asked the service for the conversation the handle names
assert service.seen[-1]["params"]["id"] == gateway.read_handle(handle)


async def test_start_forwards_the_configured_timeout_upstream(service: Any) -> None:
"""prepare() puts conversation_timeout in the start params, but the HTTP
dispatch used to rebuild the create_conversation call and drop it — the
browser was told 900 while the service kept its 3600 default. The number
the page schedules its idle warning around must be the number the service
actually enforces."""
gw = make_gateway(
service,
allowed_origins=["https://shop.example.com"],
conversation_timeout=900,
)
try:
async with asgi(gw) as http:
started = await http.post(
"/chat/", json={"method": "start"}, headers=HEADERS
)
assert started.status_code == 200
assert started.json()["timeout"] == 900
sent = service.seen[-1]
assert sent["method"] == "create_conversation"
assert sent["params"]["conversation_timeout"] == 900

# The chat path auto-creates too, and takes the same timeout.
handle = started.headers["x-chat-handle"]
chatted = await http.post(
"/chat/",
json={"message": "hi", "handle": handle},
headers=HEADERS,
)
assert chatted.status_code == 200
sent = service.seen[-1]
assert sent["method"] == "chat"
assert sent["params"]["conversation_timeout"] == 900
finally:
await gw._client.close()
38 changes: 27 additions & 11 deletions tests/unit/core/test_function_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,8 +543,8 @@ def test_execute_swml_dict_does_not_mutate_original(self) -> None:

# The original dict should NOT have 'transfer' key added
assert "transfer" not in original
# But the action's SWML should have it
assert result.action[0]["SWML"]["transfer"] == "true"
# The action carries it beside the document
assert result.action[0]["transfer"] == "true"

def test_execute_swml_sdk_object_with_to_dict(self) -> None:
"""Test execute_swml with an SDK object that has to_dict()"""
Expand All @@ -569,19 +569,26 @@ def test_execute_swml_invalid_type_list(self) -> None:
FunctionResult().execute_swml([1, 2, 3])

def test_execute_swml_with_transfer_true(self) -> None:
"""Test execute_swml with transfer=True adds transfer key"""
"""transfer is a SIBLING of the SWML key — the platform's documented
action shape, and the one connect()/swml_transfer() emit. Inside the
document it is not a SWML key and the call never exits the agent."""
swml_dict = {"version": "1.0.0", "sections": {"main": []}}
result = FunctionResult().execute_swml(swml_dict, transfer=True)

action = result.action[0]
assert action["SWML"]["transfer"] == "true"
assert action["transfer"] == "true"
assert "transfer" not in action["SWML"]
# Same action shape as the live-proven connect() helper
connect_action = FunctionResult().connect("+15551234567").action[0]
assert set(action.keys()) == set(connect_action.keys())

def test_execute_swml_with_transfer_false(self) -> None:
"""Test execute_swml with transfer=False does not add transfer key"""
swml_dict = {"version": "1.0.0", "sections": {"main": []}}
result = FunctionResult().execute_swml(swml_dict, transfer=False)

action = result.action[0]
assert "transfer" not in action
assert "transfer" not in action["SWML"]

def test_execute_swml_chaining(self) -> None:
Expand Down Expand Up @@ -996,8 +1003,10 @@ def test_tap_default_params(self) -> None:
swml = result.action[0]["SWML"]
tap_params = swml["sections"]["main"][0]["tap"]
assert tap_params["uri"] == "rtp://192.168.1.1:5000"
# Default params should not be included
assert "direction" not in tap_params
# direction is always emitted: the SWML verb's own default is "speak",
# so leaving it out would silently tap less than the documented "both".
assert tap_params["direction"] == "both"
# Params whose helper defaults match the verb defaults stay omitted.
assert "codec" not in tap_params
assert "rtp_ptime" not in tap_params

Expand Down Expand Up @@ -1040,11 +1049,18 @@ def test_tap_negative_rtp_ptime(self) -> None:
with pytest.raises(ValueError, match="rtp_ptime must be a positive integer"):
FunctionResult().tap("rtp://1.2.3.4:5000", rtp_ptime=-10)

def test_tap_direction_hear(self) -> None:
"""Test tap with direction=hear"""
result = FunctionResult().tap("rtp://1.2.3.4:5000", direction="hear")
def test_tap_direction_listen(self) -> None:
"""Test tap with direction=listen (the verb's name for the hear side)"""
result = FunctionResult().tap("rtp://1.2.3.4:5000", direction="listen")
tap_params = result.action[0]["SWML"]["sections"]["main"][0]["tap"]
assert tap_params["direction"] == "hear"
assert tap_params["direction"] == "listen"

def test_tap_direction_hear_is_rejected(self) -> None:
""""hear" was never a SWML tap direction — the verb's enum is
speak/listen/both, so emitting it produced a tap the platform
rejects. A loud error beats a silent no-op tap."""
with pytest.raises(ValueError, match="direction must be one of"):
FunctionResult().tap("rtp://1.2.3.4:5000", direction="hear") # type: ignore[arg-type] # intentional invalid input

def test_tap_chaining(self) -> None:
"""Test tap returns self for chaining"""
Expand Down Expand Up @@ -1166,7 +1182,7 @@ def test_validated_closed_sets_declared_as_literal(self) -> None:
literal_cases = [
(FunctionResult.record_call, "format", ("wav", "mp3", "mp4")),
(FunctionResult.record_call, "direction", ("speak", "listen", "both")),
(FunctionResult.tap, "direction", ("speak", "hear", "both")),
(FunctionResult.tap, "direction", ("speak", "listen", "both")),
(FunctionResult.tap, "codec", ("PCMU", "PCMA")),
]
for fn, param, expected in literal_cases:
Expand Down
9 changes: 5 additions & 4 deletions tests/unit/core/test_swml_service_swaig.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,14 @@ def test_can_emit_ai_sidecar_verb(self) -> None:
svc.add_section("main")
ok = svc.add_verb_to_section("main", "answer", {})
assert ok
# ai_sidecar isn't in the live SWML schema yet — bypass via raw doc.
# Once the schema lands, callers will use add_verb_to_section directly.
svc._current_document["sections"]["main"].append({"ai_sidecar": {
# ai_sidecar landed in the bundled schema (4645d48), so the supported
# path works — and routes the verb through schema validation.
ok = svc.add_verb_to_section("main", "ai_sidecar", {
"prompt": "real-time copilot",
"lang": "en-US",
"direction": ["remote-caller", "local-caller"],
}})
})
assert ok
rendered = json.loads(svc.render_document())
verbs = [list(v.keys())[0] for v in rendered["sections"]["main"]]
assert "answer" in verbs
Expand Down