Call¶
The Call object drives a back-to-back user agent (B2BUA). Unlike the proxy
Request, a Call owns both legs — it can dial, fork, bridge, rewrite either
leg's URIs, and anchor media. It is passed to the @b2bua.* handlers.
A B2BUA call with two legs (A-leg = caller, B-leg = callee).
The call object is the primary interface for B2BUA scripts. It tracks call state and provides methods to dial, fork, reject, and terminate.
Example::
@b2bua.on_invite
def new_call(call):
contacts = registrar.lookup(call.ruri)
if not contacts:
call.reject(404, "Not Found")
return
call.fork([c.uri for c in contacts], strategy="parallel")
@b2bua.on_bye
def call_ended(call, initiator):
log.info(f"Call ended by {initiator.side}-leg")
call.terminate()
active_route
property
¶
The carrier :class:~siphon_sdk.lcr.Route that won an LCR sequence
(call.route(...)), or None for a non-LCR call. Read in
@b2bua.on_answer / on_bye to stamp the carrier onto a CDR.
route_attempts
property
¶
Every carrier attempt that FAILED before this call settled, oldest
first — the counterpart to :attr:active_route, which names only the
winner.
Each entry is a dict with carrier_id, status, elapsed_ms
and dialed. Empty for a non-LCR call, and for an LCR call whose
first carrier answered. Available wherever the Call is, so a call
that answered after burning a carrier can still record which one it
burned — siphon stamps the same list onto the CDR as lcr_attempts.
dialed is False when siphon never put an INVITE on the wire for
that carrier — its gateway group was unknown or entirely down, or its
destination would not resolve. status is then siphon's own verdict
on the route, not the carrier's answer, so filter on it before counting
a failure against a carrier: a local DNS or gateway problem is not the
carrier's fault and does not belong in their quality figures.
Example::
@b2bua.on_answer
def answered(call, reply):
for attempt in call.route_attempts:
if not attempt["dialed"]:
continue # siphon never reached this carrier
log.warn(f"carrier {attempt['carrier_id']} failed "
f"{attempt['status']} after {attempt['elapsed_ms']}ms")
flow
property
¶
The inbound flow this call's INVITE arrived on.
The B2BUA twin of :attr:Request.flow. None when the dispatcher
had no transport binding to build one from (an internally-originated
call, or a Call constructed in a test without flow=).
The point of it is RFC 5626 connection reuse: a :class:Contact saved
at REGISTER time carries the flow the registration arrived on, so a
call can be authorised by matching the two rather than by challenging
every INVITE with a 407::
@b2bua.on_invite
def on_invite(call):
bindings = registrar.lookup(str(call.from_uri))
if any(c.flow == call.flow for c in bindings):
call.dial(str(call.ruri))
else:
call.reject(403, "Forbidden")
On a stream transport (TCP/TLS/WS/WSS) that comparison is an exact match on one accepted socket — far stronger than a source-address check, which is worthless behind carrier NAT where every subscriber shares an address. On UDP there is no connection, so the flow carries no more assurance than the address does.
The match survives the UE reusing the connection across many calls: the connection id identifies the socket, not the transaction.
auth_user
property
writable
¶
Username the A-leg authenticated as, or None if never challenged.
The B2BUA twin of request.auth_user. Set by
auth.require_proxy_digest(call, realm) /
auth.require_www_digest(call, realm) in @b2bua.on_invite once
the caller answers the challenge correctly, and carried onto the call's
CDR as auth_user.
Example::
@b2bua.on_invite
async def new_call(call):
if not await auth.require_proxy_digest(call, realm="example.com"):
return # 407 armed; siphon answers the A-leg
log.info(f"call from {call.auth_user}")
call.dial(call.ruri)
Writable, like request.auth_user. A deployment whose
authentication identity is not its subscriber identity reduces the
credential to the identity it wants downstream, after the challenge
has been answered::
@b2bua.on_invite
async def new_call(call):
if not await auth.require_proxy_digest(call, realm="example.com"):
return
call.auth_user = normalise(call.auth_user)
Assigning it before the challenge is answered asserts an identity that was never proven, so set it only on the success path.
local_tag
property
¶
The UAS To-tag siphon minted for this call's A-leg.
siphon owns this tag: it is generated with the A-leg dialog, so it is
readable from the first handler onwards — before any response goes
out — and it is the tag stamped on every response the framework sends,
from the progress() 18x through the answer() 2xx and on into
in-dialog requests. It does not change for the life of the dialog.
None only when the call is no longer live.
Read it when something outside siphon has to agree with siphon about
the dialog's identity — an external media controller keying an
offer/answer on (call-id, from-tag, to-tag) needs the same to-tag
siphon put on the wire, and minting its own there desynchronises the
media answer from the dialog.
Example::
@b2bua.on_invite
def new_call(call):
answer_sdp = media_control.answer(
call_id=call.call_id,
to_tag=call.local_tag,
offer=call.body,
)
call.answer(200, "OK", answer_sdp, "application/sdp")
In tests, pass local_tag= to pin it, or local_tag=None to
simulate reading it on a call that has already gone away.
media
property
¶
Handle for media anchoring operations.
Example::
call.media.anchor(engine="rtpengine", profile="wss_to_rtp")
call.media.release()
refer_to
property
¶
Refer-To URI from an incoming REFER request.
Available in @b2bua.on_refer handlers. Returns the URI the
remote party wants to transfer the call to, or None if no
REFER is pending.
Example::
@b2bua.on_refer
def handle_refer(call):
log.info(f"Transfer to {call.refer_to}")
call.accept_refer()
refer_side
property
¶
Which leg sent the REFER: "a" (caller) or "b" (callee).
Available in @b2bua.on_refer handlers, matching the
initiator.side convention in @b2bua.on_bye. None when no
REFER is pending.
The party that survives the transfer is the peer of this one, which
is what decides the media profile the surviving pair needs — see
:meth:accept_refer. At a mixed edge (SRTP on one side, plain RTP on
the other) the right profile differs depending on which side is
leaving.
Example::
@b2bua.on_refer
def handle_refer(call):
a_leg_is_secure = call.from_gateway("teams")
referrer_is_secure = a_leg_is_secure == (call.refer_side == "a")
# The secure party leaving leaves two plain-RTP ends behind.
profile = "rtp_passthrough" if referrer_is_secure else "srtp_to_rtp"
call.accept_refer(mode="terminate", profile=profile)
refer_replaces
property
¶
Parsed Replaces parameter from the Refer-To header.
Returns a dict with four keys — call_id, from_tag,
to_tag and early_only (a bool) — if the REFER includes a
Replaces header (attended transfer, RFC 3891), or None for a blind
transfer. early_only is True when the Replaces carried the
early-only flag (match only a dialog still in the early state).
Example::
@b2bua.on_refer
def handle_refer(call):
repl = call.refer_replaces
if repl:
log.info(
f"Attended transfer, replaces {repl['call_id']} "
f"(early_only={repl['early_only']})"
)
ro_authorizations
property
¶
Every ro_authorize call made on this call (for test assertions).
max_duration
property
¶
The cap this call carries on how long it may stay answered, in
seconds, or None when it inherits b2bua.max_call_duration_secs.
Read-only view of what max_duration= / :meth:set_max_duration
recorded, for test assertions.
charging_params
property
¶
List of (name, value) tuples stashed via
:meth:set_charging_param. Test helper.
from_gateway
¶
Check if the A-leg source IP is a member of a gateway group.
The B2BUA equivalent of request.from_gateway — returns True
when the A-leg caller's source IP is one of the resolved addresses of
the gateway group group_name (configured under gateway.groups
in siphon.yaml, or via gateway.add_group). This is siphon's
answer to Kamailio ds_is_from_list() / OpenSIPS ds_is_in_list()
— a routing-direction / trust predicate that replaces hardcoded source
CIDRs.
The match is on IP only (source port ignored) against every resolved address in the group, so a hostname that round-robins across many IPs matches on any of them.
Infallible: returns False (never raises) when the group does not
exist, no gateway is configured, or the source IP does not parse.
Security: on connection-oriented transports (TCP/TLS/WS/WSS) the source
IP is handshake-verified and trustworthy as an authorization signal; on
UDP it is spoofable, so from_gateway there is a best-effort
direction hint, not an auth gate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
group_name
|
str
|
Name of the gateway group to test membership against. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Example::
@b2bua.on_invite
def on_invite(call):
if call.from_gateway("teams"):
# Inbound from Microsoft Teams — bridge to the PBX.
call.dial("sip:pbx.internal:5060")
else:
call.reject(403, "Forbidden")
source_ip_in
¶
Check if the A-leg source IP is within any of the given CIDR ranges.
The B2BUA counterpart of :meth:Request.source_ip_in. Use it to gate on
a peer's published source subnets directly, when that peer sources SIP
from a whole range rather than only the IPs its signalling FQDNs resolve
to — the case :meth:from_gateway (which tracks the destinations' DNS)
cannot cover. Accepts IPv4 and IPv6 CIDRs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cidr_list
|
list[str]
|
List of CIDR strings (e.g. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
bool
|
the source IP does not parse. |
Example::
if call.source_ip_in(["203.0.113.0/24", "2001:db8::/32"]):
...
ro_authorize
async
¶
ro_authorize(
*,
subscription_id: Optional[str] = None,
subscription_id_type: Optional[str] = None
) -> dict
Reserve prepaid credit (Ro CCR-INITIAL) BEFORE dialing the B-leg — the
reserve-before-connect gate. Await it in @b2bua.on_invite and branch:
Example::
@b2bua.on_invite
async def on_invite(call):
decision = await call.ro_authorize()
if not decision["authorized"]:
call.reject(402, "Payment Required") # no B-leg dialed
return
call.dial("sip:bob@carrier") # credit reserved
On a grant siphon opens the credit-control session, re-authorizes on the
OCS cadence, disconnects mid-call on exhaustion, and sends
CCR-TERMINATION on BYE. subscription_id overrides the charged
identity (a sip: URI is typed as a SIP URI, never as an E.164
number); when omitted it comes from the ro.charge config. Returns
{"authorized": bool, "result_code": int|None, "granted_time": int|None,
"session_id": str|None}.
Tests can force the outcome with :meth:set_ro_authorize_result and
assert on :attr:ro_authorizations.
set_ro_authorize_result
¶
set_ro_authorize_result(
authorized: bool,
*,
result_code: Optional[int] = None,
granted_time: Optional[int] = None,
session_id: Optional[str] = None
) -> None
Test hook — pin what the next :meth:ro_authorize returns (e.g. a
4012 denial so a script's call.reject(402) branch is exercised).
reject
¶
Reject the call with an error response.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
int
|
SIP status code (e.g. 404, 486, 503). |
required |
reason
|
str
|
Reason phrase. |
required |
Example::
call.reject(486, "Busy Here")
answer
¶
answer(
code: int,
reason: str,
body: Union[str, bytes, None] = None,
content_type: str | None = None,
) -> None
UAS-mode answer — send a final 2xx response to the inbound INVITE immediately, without bridging to a B-leg.
The response goes on the wire the moment this is called (not deferred to
when the handler returns), so an async handler can answer and then
keep working — e.g. play a prompt to completion before starting echo —
without delaying the 200 OK. Synchronous; no await needed.
Answering the call itself makes siphon the caller's only UAS, so a
caller that Require-s an extension siphon does not implement (any
but 100rel, timer, replaces, sec-agree) is refused
420 Bad Extension with Unsupported instead, whatever the
header policy, and the call ends (RFC 3261 §8.2.2.3).
When a reliable provisional that carried SDP (a progress() 183 to a
caller that requires 100rel) has not been PRACKed yet, the 2xx waits
for the caller's PRACK and goes out right after siphon's 200 for it
(RFC 3262 §3).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
int
|
Final 2xx status code (200, 202, etc.). |
required |
reason
|
str
|
Reason phrase. |
required |
body
|
Union[str, bytes, None]
|
Optional response body ( |
None
|
content_type
|
str | None
|
Content-Type for the body (e.g. |
None
|
Example::
@b2bua.on_invite
async def on_invite(call):
await rtpengine.offer(call, profile="ivr")
call.answer(200, "OK", body=call.body, content_type="application/sdp")
await rtpengine.play_media(call, file=prompt) # 200 already sent
await rtpengine.echo(call)
progress
¶
progress(
code: int,
reason: str = "Ringing",
body: Union[str, bytes, None] = None,
content_type: str | None = None,
) -> None
UAS-mode provisional — send a 1xx response to the inbound INVITE
immediately (e.g. 183 Session Progress with early-media SDP, or
180 Ringing). Does not answer the call: the handler must still
answer() / dial() / reject() for a final response.
To a caller that sent Require: 100rel a 101-199 goes out reliably,
and so does one carrying SDP to a caller that only sent
Supported: 100rel (RFC 3262 §3): siphon adds Require: 100rel and its own RSeq,
retransmits it until the caller's PRACK, answers that PRACK itself, and
sends a later provisional only after it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
int
|
Provisional status code (must be 1xx; 100 carries no To-tag). |
required |
reason
|
str
|
Reason phrase. |
'Ringing'
|
body
|
Union[str, bytes, None]
|
Optional response body ( |
None
|
content_type
|
str | None
|
Content-Type for the body (e.g. |
None
|
Example::
call.progress(183, "Session Progress", body=sdp, content_type="application/sdp")
handover
¶
handover(
app: str,
on_lost: str | None = None,
deadline_ms: int | None = None,
vars: dict[str, str] | None = None,
answer: bool = False,
profile: str | None = None,
ws_uri: str | None = None,
) -> None
Hand this call over to an out-of-process control application (the ARI
Stasis model). siphon holds the INVITE transaction un-dialed, sends a
keep-alive 180, registers the call with the control plane, and emits
a StasisStart (with the full SIP context) to the owning connection.
The external app then drives the call (answer / play / dtmf / bridge /
hangup) over the control WebSocket.
A handoff deadline protects against an absent/slow controller: if no
controller accepts and acts in time, a default action fires (503 by
default), so a dead controller degrades instead of hanging calls.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app
|
str
|
The control app name (must be configured under |
required |
on_lost
|
str | None
|
What to do if the owning connection is lost mid-call —
|
None
|
deadline_ms
|
int | None
|
Handoff deadline in milliseconds; |
None
|
vars
|
dict[str, str] | None
|
Per-call variables seeded into the control channel, readable +
writable by the app via |
None
|
answer
|
bool
|
Answer-first (AI-park) mode. When |
False
|
profile
|
str | None
|
Answer-first only — the media profile to anchor with
(default |
None
|
ws_uri
|
str | None
|
Answer-first only — the per-call WebSocket bridge URI the media
engine dials out for this leg's audio (computed per session /
tenant). Supports |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Example::
@b2bua.on_invite
async def route(call):
if is_ai_number(call.to_uri):
call.handover("ai-app", answer=True,
ws_uri="wss://ai.example/stream/{call_id}")
elif is_ivr_number(call.to_uri):
call.handover("ivr-app", on_lost="hangup", deadline_ms=3000,
vars={"queue": "support"})
else:
call.dial(call.ruri)
dial
¶
dial(
uri: str,
timeout: int = 30,
max_duration: Optional[int] = None,
next_hop: Optional[str] = None,
flow: Optional["Flow"] = None,
header_policy: Optional[str] = None,
copy: Optional[list[str]] = None,
strip: Optional[list[str]] = None,
translate: Optional[list[tuple[str, str]]] = None,
route: Optional[list[str]] = None,
send_socket: Optional[str] = None,
auth_passthrough: bool = False,
number_policy: Optional[str] = None,
format: Optional[str] = None,
) -> None
Dial a single B-leg target.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
uri
|
str
|
Destination SIP URI — drives the B-leg R-URI. |
required |
timeout
|
int
|
How long the B-leg may ring before siphon gives up,
in seconds. On expiry siphon CANCELs, fires
|
30
|
max_duration
|
Optional[int]
|
How long the call may stay answered, in seconds.
A different clock from |
None
|
next_hop
|
Optional[str]
|
Optional routing destination. When set, the new
INVITE's R-URI is still built from |
None
|
flow
|
Optional['Flow']
|
Captured inbound :class: |
None
|
header_policy
|
Optional[str]
|
Qualified policy name selecting which header
policy the framework applies when building the B-leg
INVITE and forwarding responses back to the A-leg.
Defaults to |
None
|
copy
|
Optional[list[str]]
|
Per-call delta — headers to copy verbatim regardless of
the preset's default verb (e.g. |
None
|
strip
|
Optional[list[str]]
|
Per-call delta — headers to strip regardless of the
preset's default verb (e.g. |
None
|
translate
|
Optional[list[tuple[str, str]]]
|
Per-call delta — |
None
|
route
|
Optional[list[str]]
|
Route header set prepended to the B-leg INVITE after the
A-leg Route/Record-Route are stripped. Carries the captured
IMS Service-Route on MO calls so the request traverses the
originating S-CSCF (RFC 3608). Each entry is a full route
value, e.g. |
None
|
send_socket
|
Optional[str]
|
Optional egress socket pin
( |
None
|
auth_passthrough
|
bool
|
Relay B-leg authentication to the caller
end-to-end instead of siphon answering it (RFC 3261 §22.3).
When |
False
|
number_policy
|
Optional[str]
|
Named E.164 number policy (from |
None
|
Example::
# Basic dial (uses configured default policy)
call.dial("sip:bob@10.0.0.2:5060", timeout=30)
# 30s to answer, then at most an hour of talk time.
call.dial("sip:bob@10.0.0.2:5060", timeout=30, max_duration=3600)
# Device-driven proxy auth: let the extension authenticate to the
# PBX itself; siphon just relays the challenge and credentials.
call.dial("sip:bob@pbx.example.com:5060", auth_passthrough=True)
# IMS edge: stamp canonical IMPU on R-URI, route via I-CSCF,
# apply the trust-domain-boundary preset for outbound hygiene.
call.dial(
"sip:1000@ims.mnc001.mcc001.3gppnetwork.org",
next_hop="sip:192.0.2.178:4060",
header_policy="ims-trust-domain-boundary@2026",
copy=["X-Operator-Tag"],
strip=["History-Info"],
)
# Emergency call — keep PAI / Reason / Geolocation through a
# trunk edge that would otherwise strip them.
call.dial(
"sip:911@psap.example.com",
header_policy="sip-trunk-edge@2026",
copy=["Geolocation", "Geolocation-Routing",
"P-Asserted-Identity", "Reason"],
)
fork
¶
fork(
targets: list[Union[str, Contact]],
strategy: str = "parallel",
timeout: int = 30,
max_duration: Optional[int] = None,
header_policy: Optional[str] = None,
copy: Optional[list[str]] = None,
strip: Optional[list[str]] = None,
translate: Optional[list[tuple[str, str]]] = None,
send_socket: Optional[str] = None,
auth_passthrough: bool = False,
number_policy: Optional[str] = None,
format: Optional[str] = None,
) -> None
Fork to multiple B-leg targets.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
targets
|
list[Union[str, Contact]]
|
List of URI strings or :class: |
required |
strategy
|
str
|
|
'parallel'
|
timeout
|
int
|
Per-branch ring timeout in seconds. On expiry siphon
CANCELs the branches still ringing and answers the caller
|
30
|
max_duration
|
Optional[int]
|
Cap on how long the call may stay answered, in
seconds — same semantics as :meth: |
None
|
header_policy
|
Optional[str]
|
Header policy applied to every branch of the fork —
same semantics as :meth: |
None
|
copy
|
Optional[list[str]]
|
Per-call header copy deltas — same semantics as :meth: |
None
|
strip
|
Optional[list[str]]
|
Per-call header strip deltas — same semantics as :meth: |
None
|
translate
|
Optional[list[tuple[str, str]]]
|
Per-call header translation deltas — same semantics as
:meth: |
None
|
send_socket
|
Optional[str]
|
Optional egress socket pin applied to every branch
(same |
None
|
auth_passthrough
|
bool
|
Relay B-leg authentication to the caller
end-to-end — same semantics as :meth: |
False
|
number_policy
|
Optional[str]
|
Named E.164 number policy applied to every branch
target plus the A-leg identity headers — same semantics as
:meth: |
None
|
Example::
contacts = registrar.lookup(call.ruri)
# Pass Contact objects so WebSocket callees route over their flow.
call.fork(contacts, strategy="parallel", timeout=30)
route
¶
route(
routes: list["Route"],
timeout: int = 30,
max_duration: Optional[int] = None,
send_socket: Optional[str] = None,
) -> None
Route the call across an ordered list of carrier :class:~siphon_sdk.lcr.Route
objects with sequential failover — B2BUA-only LCR execution.
The carriers (from await lcr.route(call), optionally filtered /
reordered) are tried cheapest-first: dial the first routable carrier
(a gateway_group resolved to a healthy member, else next_hop /
ruri; the dialled number shaped by the route's number_policy,
else b2bua.default_number_policy, with any tech_prefix
prepended to the shaped number and headers injected), and on a
reroute cause advance to the next — each attempt a fresh B-leg dialog.
On answer, :attr:active_route is the carrier that won.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
routes
|
list['Route']
|
Ordered carriers (cheapest first). |
required |
timeout
|
int
|
Default ring timeout (seconds) for a route without its own
|
30
|
max_duration
|
Optional[int]
|
Cap on how long the call may stay answered, in
seconds — same semantics as :meth: |
None
|
send_socket
|
Optional[str]
|
Optional egress socket pin applied to every attempt. |
None
|
Example::
@b2bua.on_invite
async def route(call):
decision = await lcr.route(call)
if decision and decision.routes:
call.route(decision.routes)
terminate
¶
Terminate the call (send BYE to both legs).
Also honoured from @b2bua.on_answer, where it fails the call rather
than connecting it: the B-leg has answered by then but the A-leg has
not — its 2xx is only sent once the handler returns — so the caller
receives a 500, the answered B-leg is ACKed and BYEd, and no
answer-time charging is reported. Use it when the handler discovers the
call cannot work, typically because the media backend refused the
answer. An exception out of the handler has the same effect.
Example::
@b2bua.on_bye
def call_ended(call, initiator):
call.terminate()
@b2bua.on_answer
def answered(call, reply):
if not media_ok(reply):
call.terminate()
set_max_duration
¶
Cap how long this call may stay answered, in seconds.
The same knob as max_duration= on :meth:dial / :meth:fork /
:meth:route, reachable from a call that never dials — a UAS-mode
:meth:answer (IVR, announcement) or a :meth:handover — which would
otherwise be stuck on the configured ceiling.
The clock starts at the answer. On expiry siphon BYEs both legs
through the ordinary teardown (CDR, charging stop, media release) with
Reason: Q.850;cause=102; no Python handler fires. 0 means
uncapped, overriding a configured b2bua.max_call_duration_secs.
Example::
@b2bua.on_invite
def ivr(call):
call.set_max_duration(600) # 10 minutes, then hang up
call.answer(200, "OK")
accept_refer
¶
accept_refer(
target: Optional[str] = None,
next_hop: Optional[str] = None,
mode: Optional[str] = None,
profile: Optional[str] = None,
number_policy: Optional[str] = None,
format: Optional[str] = None,
) -> None
Accept an incoming REFER and honour the transfer.
Call this from a @b2bua.on_refer handler to proceed with the
transfer the remote party asked for.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Optional[str]
|
Optionally rewrite the transfer destination before
honouring it. Defaults to :attr: |
None
|
next_hop
|
Optional[str]
|
Optionally steer egress to a specific next-hop, exactly
like |
None
|
mode
|
Optional[str]
|
How siphon honours the REFER:
The new leg is dialled directly: |
None
|
profile
|
Optional[str]
|
Media profile for the pairing the transfer creates. Required whenever the call is anchored with a
direction-bound profile — one whose offer and answer halves
describe different sides, such as Direction-bound built-ins: |
None
|
number_policy
|
Optional[str]
|
Reshape the transferred leg's number, exactly as
Without it the target goes out in whatever shape the
referrer named it in. A Teams Terminate mode only: in |
None
|
format
|
Optional[str]
|
The inline form of the same thing, exactly as on
:meth: |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Example::
@b2bua.on_refer
def handle_refer(call):
# Blind transfer — let siphon terminate + re-bridge (default).
call.accept_refer()
@b2bua.on_refer
def handle_refer(call):
# Steer the referred-to leg out a specific trunk, transparently.
call.accept_refer(next_hop="sip:trunk.example.com:5060",
mode="transparent")
@b2bua.on_refer
def handle_refer(call):
# SRTP edge: the SRTP party is the one leaving, so the pair
# that remains is plain RTP on both sides.
call.accept_refer(target=call.refer_to, mode="terminate",
profile="rtp_passthrough")
@b2bua.on_refer
def handle_refer(call):
# Trunk takes bare digits; the Refer-To names +E.164.
call.accept_refer(target=target, next_hop=gw.uri,
mode="terminate", profile="rtp_passthrough",
number_policy="carrier-plain@2026")
@b2bua.on_refer
def handle_refer(call):
# Same thing inline, with no number_policies: block to name.
call.accept_refer(target=target, next_hop=gw.uri,
mode="terminate", profile="rtp_passthrough",
format="plain")
reject_refer
¶
Reject an incoming REFER.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
int
|
SIP status code (e.g. 403, 603). |
required |
reason
|
str
|
Reason phrase. |
required |
Example::
@b2bua.on_refer
def handle_refer(call):
call.reject_refer(403, "Forbidden")
refer
¶
Originate an outbound REFER — siphon is the referrer.
Use this when siphon itself drives the transfer, e.g. a UAS/IVR
offload that answers the call, plays a menu, then transfers the caller
onward. This is the mirror of :meth:accept_refer (which honours a
REFER siphon received).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
str
|
The Refer-To URI — where the remote party should be transferred to. |
required |
replaces
|
Optional[dict]
|
For an attended transfer, a dict identifying the
dialog to replace (RFC 3891) with keys |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Note
This is a deferred call action — it is honoured after the
handler returns, so it works from a call-scoped handler like
@b2bua.on_answer. From an out-of-band event callback
(@rtpengine.on_dtmf, a timer) where no call is in scope and
deferred actions are no-ops, use the imperative
:func:b2bua.refer (keyed by Call-ID) instead.
Example::
@b2bua.on_answer
def on_answer(call, reply):
# Blind transfer the freshly answered call onward.
call.refer("sip:+15550142@example.com")
@b2bua.on_answer
def attended(call, reply):
call.refer(
"sip:+15550142@example.com",
replaces={"call_id": "held-dialog@example.com",
"from_tag": "ft-held",
"to_tag": "tt-held"},
)
session_timer
¶
Run an RFC 4028 session timer on this call, overriding session_timer:.
siphon negotiates a session timer on each dialog of the call: the callee's, where siphon sends the INVITE, and the caller's, where siphon answers it. It refreshes the dialogs it ends up the refresher of and ends the call when a session runs out on either.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
expires
|
int
|
Session interval in seconds (default 1800). |
1800
|
min_se
|
int
|
Smallest session interval accepted, in seconds (default 90). |
90
|
refresher
|
str
|
Who siphon would have refresh each dialog, where the
negotiation leaves it the choice: |
'b2bua'
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Example::
@b2bua.on_invite
def new_call(call):
call.session_timer(1800, min_se=90, refresher="b2bua")
call.dial("sip:bob@example.com")
keep_call_id
¶
Copy the A-leg Call-ID to the B-leg instead of generating a new one.
By default the B2BUA generates a fresh Call-ID for each B-leg to fully decouple the two SIP dialogs (proper topology hiding). Call this method if you need the trunk to see the same Call-ID as the originating side.
Note: the From-tag is always regenerated regardless — it must be unique per leg.
Example::
@b2bua.on_invite
def on_invite(call):
call.keep_call_id() # trunk sees same Call-ID
call.dial("sip:trunk@carrier.example.com")
set_credentials
¶
Set outbound credentials for B-leg digest authentication.
When the B-leg returns 401/407, SIPhon automatically retries the
INVITE with these credentials instead of firing on_failure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
username
|
str
|
Digest username. |
required |
password
|
str
|
Digest password. |
required |
Example::
@b2bua.on_invite
def on_invite(call):
call.set_credentials("trunk_user", "s3cret")
call.dial("sip:gw@carrier.example.com")
set_ruri_user
¶
Set the user part of the Request-URI.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
New user part (e.g. |
required |
Example::
@b2bua.on_invite
async def on_invite(call):
call.set_ruri_user("+33123456789")
call.dial("sip:gw@carrier.example.com")
restrict_caller_id
¶
Withhold the calling party's identity on the B-leg (CLIR).
RFC 3323 §4.1 / 3GPP TS 24.607::
@b2bua.on_invite
def route(call):
if caller_withheld(call):
call.restrict_caller_id()
call.dial(str(call.ruri))
Frombecomes"Anonymous" <sip:anonymous@anonymous.invalid>, keeping its dialog tag.Privacy: idis asserted (RFC 3325 §7), appended to any existingPrivacyvalue rather than replacing it.P-Asserted-Identityis left intact, carrying the real identity to the trusted next hop — that is how the network stays able to identify the caller for regulatory and emergency purposes.P-Preferred-Identityis removed: it is the UA's request for what to assert, and forwarding it past a privacy boundary re-leaks the number.
Setting Privacy: id by hand while leaving the real number in
From leaks it to every carrier that renders From rather than
PAI — which defeats CLIR while looking like it works. This moves both
together.
Call it after any identity reshaping: anonymisation is the last step,
or a number policy will try to reformat anonymous as a number. The
LCR twin is a route's caller_id_presentation="restricted".
set_caller_id
¶
Present number as the calling party.
Applied to From and to P-Asserted-Identity /
P-Preferred-Identity where present, preserving the dialog tag —
which is why this exists rather than a set_header("From", ...): the
B-leg From host is rewritten after the script runs, and a From
set without a tag drops the mandatory dialog tag (RFC 3261 §8.1.1.3), a
failure that only surfaces later on the ACK.
Unlike a number policy, which reshapes the format of whatever number
is already there, this substitutes a different one. The LCR twin is a
route's caller_id.
Returns True if anything was rewritten.
set_from_user
¶
Set the user part of the From header URI.
Preserves display name and tag parameter while replacing the user part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
New user part (e.g. |
required |
Example::
@b2bua.on_invite
async def on_invite(call):
call.set_from_user("+33123456789")
call.dial("sip:gw@carrier.example.com")
set_to_user
¶
Set the user part of the To header URI.
Mirrors :meth:set_from_user / :meth:set_ruri_user for the To
header. Useful at IMS edges (BGCF inbound) where the B-leg R-URI
is rewritten from a public E.164 to a short-code IMPU and
downstream nodes expect To to match.
Preserves scheme/host/port and any existing To-tag — only the
userpart changes. Must be called before :meth:dial for the
change to take effect on the B-leg INVITE.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
New user part (e.g. |
required |
Example::
@b2bua.on_invite
async def on_invite(call):
call.set_ruri_user("1000")
call.set_to_user("1000")
call.dial("sip:1000@ims.mnc001.mcc001.3gppnetwork.org")
rewrite_identities
¶
rewrite_identities(
policy: Optional[str] = None,
format: Optional[str] = None,
headers: Optional[list[str]] = None,
home: Optional[str] = None,
) -> int
Rewrite dialable identity userparts into a target E.164 shape.
Walks From, To, P-Asserted-Identity, P-Preferred-Identity (and any
opted-in header) on the A-leg INVITE, which flows to the B-leg. Pass
either a named policy from number_policies: or an inline
format ("e164" | "plain" | "international" |
"national") with an optional headers list and home
country-code override. Returns the number of headers changed. Must be
called before :meth:dial.
Example::
call.rewrite_identities("ims-e164@2026")
call.rewrite_identities(format="e164")
set_from_host
¶
Pin the host part of the B-leg From header URI.
By default the B2BUA rewrites the From URI host to its own advertised
address (topology hiding — masking the A-leg identity). At a
multitenant edge the downstream selects the tenant from the From
domain: a domainless call lands in an unauthenticated/default routing
context, so the tenant domain must survive. set_from_host() opts
this leg out of the From host-rewrite and pins the host to value.
Only the host changes; scheme/user/port/params and the From-tag are
preserved. value is a bare host (no port). Must be called before
:meth:dial for the change to take effect on the B-leg INVITE — same
model as :meth:set_from_user.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
New host (e.g. |
required |
Example::
@b2bua.on_invite
async def on_invite(call):
call.set_from_host("tenant.example.com")
call.dial(str(call.ruri), next_hop="sip:pbx.example.com:5060")
set_to_host
¶
Pin the host part of the B-leg To header URI.
By default the B2BUA rewrites the To URI host to the dial-target host.
set_to_host() pins it to value instead, so the To domain does
what the script says regardless of the routing next-hop (declarative
replacement for the raw set_header("To", "<sip:user@host>") idiom).
Only the host changes; scheme/user/port/params and any To-tag are
preserved. value is a bare host (no port). Must be called before
:meth:dial — same model as :meth:set_to_user.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
New host (e.g. |
required |
Example::
@b2bua.on_invite
async def on_invite(call):
call.set_to_user(callee)
call.set_to_host(TRUNK_DOMAIN)
call.dial(str(call.ruri))
set_from_uri
¶
Replace the entire From header URI on the B-leg INVITE.
The whole-URI form of :meth:set_from_user / :meth:set_from_host —
rewrites scheme, user, host, port and URI params in one call while
preserving the display name and From-tag. The host is also pinned (the
B-leg builder would otherwise rewrite it to the advertised address for
topology hiding — same opt-out as :meth:set_from_host). Must be called
before :meth:dial.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
New From URI, e.g.
|
required |
Example::
@b2bua.on_invite
async def on_invite(call):
call.set_from_uri("sip:1001@tenant.example.com:5060")
call.dial("sip:gw@carrier.example.com")
set_to_uri
¶
Replace the entire To header URI on the B-leg INVITE.
The whole-URI form of :meth:set_to_user / :meth:set_to_host,
preserving the display name and any To-tag. The host is also pinned
(same opt-out as :meth:set_to_host). Must be called before
:meth:dial.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
New To URI, e.g.
|
required |
set_contact_user
¶
Inject a userpart into the B-leg Contact URI, keeping siphon's advertised host:port.
The B2BUA advertises its own address as the Contact so in-dialog
requests (BYE, re-INVITE) route back through siphon; by default that
Contact is userless (RFC 3261 §8.1.1.8 puts no identity in the Contact
userpart). set_contact_user() adds a userpart while leaving the
host:port untouched, so in-dialog routing still works and the userpart
rides along — e.g. a downstream that keys a tenant/extension off the
Contact userpart, the way it does for a REGISTER Contact.
Pass an empty string to force a userless Contact. Must be called before
:meth:dial.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
Contact userpart (e.g. the extension). |
required |
Example::
@b2bua.on_invite
async def on_invite(call):
call.set_contact_user(call.from_uri.user)
call.dial("sip:gw@carrier.example.com")
set_contact_uri
¶
Replace the entire B-leg Contact URI — a full override of siphon's advertised Contact.
Power tool for edge deployments that front siphon (GRUU, edge SBC).
Overriding the host/port moves the in-dialog anchor off siphon, so the
deployment must route the far side's in-dialog requests back to siphon
or the dialog breaks. Takes precedence over :meth:set_contact_user.
value is a bare URI (no angle brackets). Must be called before
:meth:dial.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
Full Contact URI, e.g. |
required |
get_header
¶
Get the first value of a header (case-insensitive).
set_header
¶
Set (replace) a header value on the captured A-leg INVITE.
The B-leg INVITE is built from this message, and a header set here
(or removed with :meth:remove_header /
:meth:remove_headers_matching) goes out as written: the header
policy, preset and copy= / strip= / translate= deltas
alike, neither strips, rewrites nor translates it. The
framework-managed headers (Via, Call-ID, CSeq,
Max-Forwards, Content-Length, From, To, Contact,
Record-Route, Route) stay siphon's, and the number policy,
session-timer headers and LCR route headers still apply on top.
Supported and Allow set here replace the capabilities siphon
would advertise; replaces is still merged into a Supported set
this way. To relay the caller's own list::
call.set_header("Supported", call.get_header("Supported"))
set_body
¶
Replace the body of the captured A-leg INVITE.
Updates Content-Type and Content-Length to match. body
accepts str or bytes.
The B-leg INVITE is built from this message, so a body set here is the
body the callee receives — this is the dial()-time counterpart of
:meth:set_ruri_user / :meth:set_from_user and must be called
before :meth:dial / :meth:fork. The typical use is sanitising
the SDP an anchoring step left behind: rtpengine.offer(call)
rewrites the captured INVITE in place, and a script that needs to strip
or rewrite attributes before the offer goes out has nowhere else to put
the result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
Union[str, bytes]
|
Replacement body, |
required |
content_type
|
Optional[str]
|
Content-Type to set. Omit to leave the existing one in place — the body changed, the type did not. |
None
|
Example::
@b2bua.on_invite
async def new_call(call):
await rtpengine.offer(call, profile="trunk_to_ims")
cleaned = strip_unwanted_attributes(call.body)
call.set_body(cleaned, "application/sdp")
call.dial("sip:bob@example.com")
set_charging_param
¶
Stash a charging-param for the Rf B2BUA auto-emit hook.
Mirrors meth:
siphon_sdk.request.Request.set_charging_param
for B2BUA scripts that get a Call object. Recognised names
map to TS 32.299 IMS-Information AVPs; unknown names are still
captured so future siphon versions can recognise more without
breaking deployed scripts.
Example (BGCF as B2BUA)::
@b2bua.on_invite
async def on_invite(call):
gw = gateway.select("connect")
call.set_charging_param(
"outgoing-trunk-group-id", gw.attrs["group"],
)
call.dial(gw.uri)
remove_headers_matching
¶
Remove all headers whose name starts with a prefix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prefix
|
str
|
Prefix string (e.g. |
required |
Example::
call.remove_headers_matching("X-")
Placing a call: b2bua.originate¶
Every handler above is driven by a call that arrived. b2bua.originate() creates
one from nothing — click-to-dial, callbacks, outbound notification — so it works
from a timer or an event callback, where no Call object exists at all.
It returns as soon as the INVITE is on the wire, with the new leg's SIP Call-ID;
it does not wait for the callee. Ringing and answer come back through the
ordinary handlers (@b2bua.on_answer, @b2bua.on_failure, @b2bua.on_bye), and
the returned Call-ID is the handle for b2bua.terminate() / b2bua.refer().
from siphon import b2bua, timer
@timer.every(seconds=60)
def reminders():
for number in due_numbers():
b2bua.originate(
to=f"sip:{number}@carrier.example",
from_uri="sip:+14035550100@siphon.example",
from_display="Reminders",
media=True, # siphon anchors the leg on the media backend
timeout=30, # CANCELled if nobody answers in 30 s
)
Exactly one media plan is required — an INVITE with no offer and no way to answer
the callee's would connect a call with no audio: sdp= (your own offer, any
backend), body= + content_type= (the same slot with the type spelled out,
for an offer travelling inside a multipart/* body per RFC 5621 §3), or
media=True (siphon anchors it; siphon-rtp backend). Whichever spelling, the
body has to carry an SDP offer — a body that carries none raises rather than
placing a call the callee can only answer by offering into silence. The full
argument set and its failure modes are below; the out-of-process twin is the
control plane's originate verb.
A placed call runs the RFC 4028 session timer of the session_timer: block, or
the one session_timer={"expires": 1800, "min_se": 90, "refresher": "b2bua"}
sets on it (keys left out default as in call.session_timer()). The INVITE asks
for it, the callee's 2xx says who refreshes, and siphon refreshes the dialog or
releases the call just before a session the callee let run out expires.
Place an outbound call siphon owns, with no inbound INVITE behind it.
The primitive under click-to-dial, callbacks and outbound notification.
Unlike call.dial() (which builds a B-leg off a call that already
arrived), this creates a call from nothing.
Asynchronous. Returns as soon as the INVITE is on the wire, with the
new leg's SIP Call-ID — it does not wait for the callee. Ringing and
answer arrive through the ordinary handlers: @b2bua.on_answer fires
with (call, reply), @b2bua.on_failure with
(call, code, reason), @b2bua.on_bye on teardown. Feed the
returned Call-ID to b2bua.terminate() / b2bua.refer().
Exactly one media plan is required — an INVITE with no offer and no way to answer the callee's would connect a call with no audio:
sdp="v=0..."— your own offer, carried verbatim asapplication/sdp(any backend);body=…, content_type=…— the same slot with the type spelled out, for an INVITE whose offer travels as one part of amultipart/*body (RFC 5621 §3) beside a part SIP does not interpret: ISUP on a SIP-I trunk, a PIDF-LO location object, an operator-specific document. You assemble the multipart; siphon carries it verbatim and derives Content-Length from it;media=True— siphon anchors the leg on the configured media backend (siphon-rtp), sortpengine.play_media(), DTMF and the WebSocket tee all work against it.
Whichever spelling, the body must carry an SDP offer — bare
application/sdp, or a multipart/* with an application/sdp
part in it. Live siphon raises for one that carries none, because a
callee that reads the INVITE as offerless offers in its own 2xx and this
plan has nothing to answer that with (RFC 3261 §13.2.2.4).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
to
|
str
|
called party — the Request-URI and the To URI. |
required |
from_uri
|
Optional[str]
|
calling identity (From URI). Defaults to siphon's own advertised address. |
None
|
from_display
|
Optional[str]
|
From display name. |
None
|
to_display
|
Optional[str]
|
To display name. |
None
|
next_hop
|
Optional[str]
|
route the INVITE here while the R-URI keeps the called party's shape (IMS edge / trunk steering). |
None
|
p_asserted_identity
|
Optional[str]
|
|
None
|
privacy
|
Optional[str]
|
|
None
|
headers
|
Optional[dict]
|
dict of extra headers applied last. Dialog-defining headers (Via/From/To/Call-ID/CSeq/Contact/Route/…) are ignored. |
None
|
sdp
|
Optional[str]
|
your own SDP offer, carried as |
None
|
media
|
bool
|
True to have siphon anchor the leg on the media backend. |
False
|
profile
|
Optional[str]
|
media profile for |
None
|
ws_uri
|
Optional[str]
|
per-call WebSocket bridge URI for |
None
|
timeout
|
int
|
ring timeout in seconds; the call is CANCELled when it elapses. 0 disables it. |
30
|
body
|
Optional[Union[str, bytes]]
|
the INVITE body, |
None
|
content_type
|
Optional[str]
|
Content-Type for |
None
|
session_timer
|
Optional[dict]
|
the RFC 4028 session timer to run on the call, over
the |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
the new leg's SIP Call-ID. |
Raises:
| Type | Description |
|---|---|
ValueError
|
no media plan, two media plans, both spellings of the
offer, a |
In the mock, records the full argument set on originates and returns
a synthetic Call-ID. Inspect via siphon.get_b2bua().originates.
Usage::
@timer.every(seconds=60)
def reminders():
for number in due_numbers():
b2bua.originate(
to=f"sip:{number}@carrier.example",
from_uri="sip:+14035550100@siphon.example",
media=True,
)
Joining two calls: b2bua.bridge¶
b2bua.originate gives a script a second call; b2bua.bridge connects it to the
first. Both legs are named by SIP Call-ID, so it works from a timer or an event
callback where no Call object exists.
The leg named first is the anchor: it keeps its media session, and the other
joins it. The call resolves once the media has been re-pointed and the first
re-INVITE is on the wire — a bridge is two RFC 3261 §14 re-INVITEs across two
dialogs, and the far ends' verdict arrives on the control rail as
ChannelBridged / BridgeFailed.
from siphon import b2bua
@b2bua.on_answer
async def connect_the_supervisor(call):
supervisor = b2bua.originate(
to="sip:+15550142@example.com",
media=True, # siphon anchors the leg
)
# ... wait for it to answer (a @b2bua.on_answer for that leg) ...
await b2bua.bridge(call.call_id, supervisor, on_peer_hangup="hold")
unbridge parts them without ending either call: both legs stay answered, owned
and held (a=sendonly, RFC 3264 §8.4), so either can be bridged again or hung
up. Every refusal raises ValueError prefixed with a stable cause token rather
than returning a hollow success. The out-of-process twin is the control plane's
bridge verb.
Join two answered calls siphon owns, so the two parties hear each other.
The primitive under callback-and-connect and attended hand-off: every
other B2BUA verb acts on one call, this one joins two. Both legs are
named by SIP Call-ID, so it works from an event callback or a timer
where no call object is in scope.
Awaitable. It resolves once the media has been re-pointed and the
first re-INVITE is on the wire — the same "the local action was
performed" contract :meth:originate has. A bridge is two RFC 3261 §14
re-INVITEs across two dialogs; whether the far ends accept them is a
far-end outcome, delivered on the control rail as ChannelBridged /
BridgeFailed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call_id
|
str
|
SIP Call-ID of the leg that keeps its media anchor — its ports, and anything attached to them, survive the bridge. |
required |
with_call_id
|
str
|
SIP Call-ID of the leg joined to it. Its own media session is deleted; it becomes the second party on the first's. |
required |
on_peer_hangup
|
str
|
what happens to the survivor when one party leaves.
|
'hangup'
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True once the bridge has been accepted and put in motion. |
Raises:
| Type | Description |
|---|---|
ValueError
|
an |
In the mock, records {"call_id", "with_call_id", "on_peer_hangup"}
on bridges and returns True. Inspect via
siphon.get_b2bua().bridges.
Usage::
# Callback-and-connect: join the parked caller to the leg siphon
# placed for them, once that leg has answered.
@b2bua.on_answer
async def connect(call, reply):
parked = await cache.fetch("callbacks", call.call_id)
if parked:
await b2bua.bridge(parked, call.call_id)
Break a bridge, leaving both legs answered, owned and held.
Each leg falls back to exactly the state a freshly answered, unbridged
leg is in — siphon re-offers it a=sendonly (RFC 3264 §8.4; RFC 6337
§3.1 prefers that to c=0.0.0.0), so the endpoint stops sending and
hears nothing. Neither leg is hung up: that would make unbridge
indistinguishable from two :meth:terminate calls. A later
:meth:bridge re-offers sendrecv.
Awaitable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call_id
|
str
|
SIP Call-ID of either leg of the bridge. |
required |
reason
|
str
|
carried on the control-plane |
'unbridged'
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True once both legs have been held and the bridge dropped. |
Raises:
| Type | Description |
|---|---|
ValueError
|
live siphon raises when the leg is unknown, is not
bridged, or its bridge is still forming. The message carries the
same stable cause token as :meth: |
In the mock, records {"call_id", "reason"} on unbridges and
returns True. Inspect via siphon.get_b2bua().unbridges.
Usage::
@b2bua.on_bye
async def split(call, initiator):
await b2bua.unbridge(call.call_id, reason="supervisor split")
Swapping a party mid-call: b2bua.replace_peer¶
bridge joins two calls the script owns. replace_peer does something
different: it takes one answered call and swaps out one of its two parties for
somebody new, without either party's endpoint asking for it.
This is the transfer siphon already runs when it terminates an inbound REFER
(call.accept_refer(mode="terminate")), reachable when siphon is the one
deciding. It dials the target as a new leg on the same call, re-anchors the
surviving party's media onto it, and once the target answers promotes it into
the surviving pair and BYEs the leg it replaced. An IVR that has worked out
where the caller should go, a supervisor take-over, a controller handing a call
from an AI to a human.
The obvious hand-rolled version — hang up one leg, then re-INVITE the other with
new SDP — is worse in two ways that only show up in production. The caller hears
dead air for the whole ring, because the leg is gone before the target has even
been dialled. And the call's own state is left behind: no @b2bua.on_bye, no
CDR, no charging stop, no media release, and a later terminate re-BYEs a
dialog that is already dead. replace_peer keeps the replaced leg up until the
target answers and releases it through the real teardown.
from siphon import b2bua, rtpengine
@rtpengine.on_dtmf
def zero_for_an_operator(call_id, from_tag, digit, duration_ms, volume):
if digit == "0":
# The caller stays connected to the IVR while the operator's phone
# rings; if nobody picks up in 45s the call is left exactly as it was.
b2bua.replace_peer(call_id, "sip:operator@pbx.example", timeout=45)
Every refusal raises ValueError prefixed with a stable cause token — a caller
that cannot tell a refused replacement from a started one will tear down a call
that is still up. The out-of-process twin is the control plane's
replace_peer verb.
Replace one leg of an answered call with a freshly dialed target.
The transfer siphon already knows how to run, reachable without a remote
party asking for it: it dials target as a new leg on the same call,
re-anchors the surviving party's media onto it, and when the target
answers promotes it into the surviving pair and BYEs the leg it
replaced. An IVR that has decided where a caller goes next, a controller
handing a call from an AI to a human, a supervisor take-over.
The replaced leg stays up while the target rings and is released only once the target answers, so the surviving party hears ringback rather than silence, and a target that rejects or never answers leaves the original call exactly as it was.
Acts now, so it works from an out-of-band event callback
(@rtpengine.on_dtmf), a timer, or a normal handler.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call_id
|
str
|
SIP Call-ID of the call to act on. |
required |
target
|
str
|
URI to dial as the replacement. |
required |
next_hop
|
Optional[str]
|
steer egress without reshaping the R-URI, as on
:meth: |
None
|
replace_a_leg
|
bool
|
False (default) replaces the callee and keeps the caller; True does the reverse. |
False
|
profile
|
Optional[str]
|
media profile for the pair this creates. Required when the call is anchored with a direction-bound profile — the inherited one describes the party that is leaving. |
None
|
timeout
|
int
|
seconds to wait for the target to answer. 0 means no ring policy, only siphon's guard against a target that never answers. |
30
|
number_policy
|
Optional[str]
|
reshape the target's number (and the triggered
INVITE's identity headers) the way
:meth: |
None
|
format
|
Optional[str]
|
the inline form of the same thing, as on
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True once the INVITE is on the wire. It does not wait for the |
bool
|
target — the replacement completes later, and |
|
bool
|
fires for the leg that was replaced. |
Raises:
| Type | Description |
|---|---|
ValueError
|
the call is unknown, has not answered, has no peer leg
to replace, already has a replacement in flight, or the target
will not route. The message is prefixed with the stable cause
token ( |
In the mock, records every argument on replacements and returns
True; an empty target raises ValueError the way live siphon
refuses one it cannot route. Inspect via
siphon.get_b2bua().replacements.
Usage::
@rtpengine.on_dtmf
def on_ivr_dtmf(call_id, from_tag, digit, duration_ms, volume):
if digit == "0":
b2bua.replace_peer(call_id, "sip:operator@pbx.example",
timeout=45)
Logging the outbound leg: b2bua.log_dial¶
A B2BUA call says nothing at log.level: info about where it dialled. The
obvious workaround is a log.info() next to the call.dial(), and it has a real
flaw: call.dial() does not dial. It records an action that the framework
executes once the handler returns, so the line is written before the dial exists
and still claims it when the destination fails to resolve. It also logs the
string the script passed, which is not necessarily what goes on the wire — the
header policy, the number policy, and LCR's tech-prefix / retarget / CLIR steps
all still get a turn.
Turn the framework's own line on instead:
B2BUA: dialling B-leg call_id=… b_leg_call_id=… ruri=sip:…@carrier.example
next_hop=Some("sip:198.51.100.7:5060")
destination=198.51.100.7:5060 transport=udp source=…
It is emitted from the send itself, so the R-URI is the one on the wire and
b_leg_call_id is the Call-ID the far end will quote back at you. It covers
every outbound INVITE — call.dial(), each call.fork() branch, each
call.route() carrier attempt, and a REFER-terminate re-dial — so there is no
per-call-site flag to forget on one of three dial paths.
It is off by default because it is one line per call on the busiest path siphon
has, which is an operator's decision rather than an upgrade's. (The
LCR failover lines log at info
unconditionally — they fire only when a carrier fails, not on every call.)
Bounding a call: timeout and max_duration¶
A call has two clocks, and they measure different things.
timeout= bounds the ring. If nothing answers in that many seconds siphon
CANCELs the outstanding legs and fires @b2bua.on_failure with 408. Unless
the handler routes the call somewhere else or rejects it with a response of its
own, the caller gets that 408. On a call.fork() where another branch already
failed with something that outranks a timeout (a 486, say), the caller gets
that failure instead (RFC 3261 §16.7, §16.8). It stops mattering the moment a
2xx lands.
On call.route() each carrier has its own timer, and it bounds the wait for
that carrier to show progress (a 101-199) rather than for its answer. A carrier
that has rung keeps the call up to timeout=, then the call fails with 408
instead of going to the next carrier. A sequence that ends on the ring timeout
of a carrier that never sent a 101-199 fails with 503 instead, and
@b2bua.on_failure gets 503: no carrier reached the callee. So does a
sequence that moves on and finds none of the carriers left can be dialled,
whatever the carrier before them did. See
ring timeout and progress.
max_duration= bounds the talk. The clock starts at the answer, so a call
that rang for 25 seconds still gets its full talk time. When it expires siphon
BYEs both legs.
@b2bua.on_invite
def route(call):
# 30s to answer, then at most an hour connected.
call.dial(call.ruri, timeout=30, max_duration=3600)
Before this existed, an answered call was bounded by nothing but a peer BYE, a
script terminate(), or an RFC 4028 session timer where one is configured and
the far end honours it. A carrier leg that goes silent with its dialog still up
holds a call actor, a media anchor, a charging session and an RTP port pair for
as long as the process lives.
The operator backstop is the same cap applied to every call that does not ask for its own:
A call overrides it with max_duration=<seconds> and opts out of it entirely
with max_duration=0. The kwarg is on call.dial(), call.fork() and
call.route(); on a fork or an LCR sequence it is per call, not per branch or
per carrier — timeout bounds each attempt's ring, but whichever one answers
hands over a single answered call. A call that never dials at all — a UAS-mode
call.answer(), a call.handover() — reaches the same knob through
call.set_max_duration(seconds).
On expiry siphon runs the ordinary teardown: a BYE to each leg carrying
Reason: Q.850;cause=102;text="Maximum call duration exceeded" (RFC 3326), a
CDR with disconnect_initiator="timeout", Rf/Ro ACR-STOP, media released,
StasisEnd on the control rail. No Python handler fires — @b2bua.on_bye
reports which peer hung up, and here neither did, which is also how a
session-timer expiry and call.terminate() behave. The CDR is the record; its
sip_reason is what distinguishes a duration cut from a session-timer one.
One more timer ends an answered call the same way. siphon retransmits every 2xx
it sends the caller, relayed or its own call.answer(), until the caller's ACK
arrives. If none has arrived after 64×T1 (32 s), RFC 3261 §13.3.1.4 has the
session ended, and siphon sends both legs a BYE with
Reason: Q.850;cause=102;text="No ACK received", through the same teardown and
with the same CDR disconnect_initiator="timeout". An ACK that siphon processes
before the teardown runs keeps the call up.
A call that ends while its 2xx is still waiting for that ACK (the callee hangs up right after answering, or any of the teardowns above) sends the callee its BYE at once, but not the caller: RFC 3261 §15 sends no BYE on a dialog before its 2xx is ACKed. The caller keeps receiving the 2xx, and its BYE goes out right after its ACK, or at 64×T1 if the ACK never comes. The CDR, charging and media are closed when the call ends, not when that BYE goes out.
Cap how long this call may stay answered, in seconds.
The same knob as max_duration= on :meth:dial / :meth:fork /
:meth:route, reachable from a call that never dials — a UAS-mode
:meth:answer (IVR, announcement) or a :meth:handover — which would
otherwise be stuck on the configured ceiling.
The clock starts at the answer. On expiry siphon BYEs both legs
through the ordinary teardown (CDR, charging stop, media release) with
Reason: Q.850;cause=102; no Python handler fires. 0 means
uncapped, overriding a configured b2bua.max_call_duration_secs.
Example::
@b2bua.on_invite
def ivr(call):
call.set_max_duration(600) # 10 minutes, then hang up
call.answer(200, "OK")
MediaHandle¶
Returned by call.media — controls RTP anchoring for the call.
Handle for media anchoring operations on a B2BUA call.
Accessible as call.media. In the mock, anchor() and
release() are recorded as actions.
is_active
class-attribute
instance-attribute
¶
True if media is currently anchored through an RTP engine.
anchor
¶
Anchor media through an RTP engine.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
engine
|
str
|
Engine name (currently only |
'rtpengine'
|
profile
|
str
|
RTP profile — |
'srtp_to_rtp'
|
ByeInitiator¶
Identifies which side ended an answered call (surfaced on @b2bua.on_bye).