Proxy & B2BUA¶
The proxy and b2bua namespaces register the event handlers that make
routing decisions, plus the helpers those handlers lean on (rate limiting,
sanity checks, ENUM lookup) and the generic SUBSCRIBE-dialog state store.
from siphon import proxy, b2bua
@proxy.on_request
def route(request):
request.relay()
@b2bua.on_invite
async def call(call):
call.dial(call.ruri)
proxy namespace¶
Mock proxy namespace with decorator registration and utility stubs.
Decorators
@proxy.on_request/@proxy.on_request("INVITE")@proxy.on_reply@proxy.on_failure@proxy.on_cancel@proxy.on_register_reply
Example::
from siphon import proxy
@proxy.on_request("REGISTER")
def handle_register(request):
request.reply(200, "OK")
sent_requests
property
¶
List of requests sent via send_request() (for test assertions).
on_request
¶
Register a handler for incoming SIP requests.
Can be used as
@proxy.on_request— handle all methods@proxy.on_request()— same, explicit call@proxy.on_request("REGISTER")— single method filter@proxy.on_request("INVITE|SUBSCRIBE")— pipe-separated filter
A filtered handler does not replace the unfiltered one — both run, in registration order; an unfiltered handler matches every method.
They also share one action slot. reply() / relay() /
fork() assign it rather than sending, and only its final value is
executed, so a later handler silently replaces an earlier one's routing
decision::
@proxy.on_request("OPTIONS")
def probe(request):
request.reply(200, "OK") # discarded below
@proxy.on_request # also runs for OPTIONS
def route(request):
request.relay(NEXT_HOP)
The probe is not answered and then relayed — it is only relayed. Use
request.stop_propagation() to keep a decision, or branch inside one
handler rather than registering two. Side effects (set_header,
record_route, logging, metrics) do happen from every handler; it is
only the routing decision that is last-writer-wins.
(@diameter.on_request takes a filter of the same shape but
dispatches only the single most specific match: a Diameter request needs
exactly one answer, where a SIP request can legitimately interest
several handlers at once.)
on_reply
staticmethod
¶
Register a handler for SIP replies.
Handler signature: (request, reply) -> None
on_failure
staticmethod
¶
Register a handler for proxy failure (all branches failed).
Handler signature: (request, reply) -> None
on_cancel
staticmethod
¶
Register a handler for a CANCELled INVITE (RFC 3261 §9).
Handler signature: (request) -> None
Fires once, with the original INVITE, when a relayed INVITE is
CANCELled before any final response — the one teardown that neither
on_reply nor on_failure delivers (the proxy answers the CANCEL
with 487 at the transaction layer and the session is gone). Use it to
release per-call resources that no BYE will ever clear: Diameter
Rx / N5 QoS sessions, rtpengine media anchors, charging maps.
Fire-and-forget — it does not gate or alter the 487 sent to the UAC.
Example::
@proxy.on_cancel
async def handle_cancel(request):
await _release_qos(request.call_id)
await rtpengine.delete(request)
on_register_reply
staticmethod
¶
Register a handler for REGISTER replies.
Handler signature: (request, reply) -> None
send_request
async
¶
send_request(
method: str,
ruri: str,
headers: Optional[dict[str, str]] = None,
body: Optional[Any] = None,
next_hop: Optional[str] = None,
wait_for_response: bool = False,
timeout_ms: int = 2000,
) -> Any
Originate an outbound SIP request.
Always returns an awaitable — scripts must await it. Fire-and-forget
by default; when wait_for_response=True, the awaitable resolves to
a configured mock Reply (or None on timeout).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
method
|
str
|
SIP method name (e.g. "NOTIFY", "OPTIONS", "MESSAGE"). |
required |
ruri
|
str
|
Request-URI string (e.g. "sip:alice@10.0.0.1:5060"). |
required |
headers
|
Optional[dict[str, str]]
|
Optional dict of header name → value to add. When a
|
None
|
body
|
Optional[Any]
|
Optional body — |
None
|
next_hop
|
Optional[str]
|
Optional next-hop URI override. Outranks a |
None
|
wait_for_response
|
bool
|
When |
False
|
timeout_ms
|
int
|
Response timeout (not meaningfully enforced in the mock). |
2000
|
set_response_for
¶
Test helper: configure the mock reply returned by
send_request(wait_for_response=True) for a given (method, ruri).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
method
|
str
|
SIP method (e.g. "OPTIONS"). |
required |
ruri
|
str
|
Request-URI the script will pass. |
required |
reply
|
Any
|
Any object (often a |
required |
proxy utilities¶
Reached as proxy.rate_limit, proxy.sanity_check, proxy.enum_lookup, and
proxy.memory_used_pct.
Mock proxy._utils namespace.
Provides rate limiting, sanity checking, ENUM lookup, and memory stats. In the mock, these return configurable defaults.
rate_limit
¶
Check if a request is within the rate limit.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Any
|
The SIP request object. |
required |
window_secs
|
float
|
Sliding window duration in seconds. |
required |
max_requests
|
int
|
Maximum requests allowed in the window. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
In the mock, returns the value of _rate_limit_allow (default True).
sanity_check
¶
Validate request per RFC 3261 (mandatory headers, Max-Forwards, etc.).
Returns:
| Type | Description |
|---|---|
bool
|
|
In the mock, returns _sanity_check_pass (default True).
enum_lookup
async
¶
DNS NAPTR lookup for phone number to SIP URI.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
number
|
str
|
E.164 number (e.g. |
required |
suffix
|
str
|
DNS suffix (default |
'e164.arpa.'
|
service
|
str
|
Service type (default |
'E2U+sip'
|
Returns:
| Type | Description |
|---|---|
Optional[str]
|
SIP URI string or |
In the mock, looks up _enum_results dict.
memory_used_pct
¶
Process RSS memory usage as percentage (0–100).
In the mock, returns _memory_pct (default 25).
b2bua namespace¶
Mock B2BUA namespace with decorator registration.
Decorators
@b2bua.on_invite— new call@b2bua.on_early_media— provisional response with SDP (183/180)@b2bua.on_answer— call answered@b2bua.on_failure— all B-legs failed@b2bua.on_bye— call ended@b2bua.on_refer— call transfer (RFC 3515)@b2bua.on_cancel— unanswered call cancelled (RFC 3261 §9)
Imperative
b2bua.originate(to=...)— place an outbound call with no inbound INVITE behind it (records ontooriginatesfor test assertions)b2bua.terminate(call_id)— end a call by SIP Call-ID from any context (records ontoterminatesfor test assertions)b2bua.refer(call_id, target)— transfer a call by SIP Call-ID from any context (records ontorefersfor test assertions)await b2bua.bridge(call_id, with_call_id)— join two answered calls so the two parties hear each other (records ontobridgesfor test assertions)await b2bua.unbridge(call_id)— break a bridge, leaving both legs answered, owned and held (records ontounbridgesfor test assertions)
originate
¶
originate(
to: str,
from_uri: Optional[str] = None,
from_display: Optional[str] = None,
to_display: Optional[str] = None,
next_hop: Optional[str] = None,
p_asserted_identity: Optional[str] = None,
privacy: Optional[str] = None,
headers: Optional[dict] = None,
sdp: Optional[str] = None,
media: bool = False,
profile: Optional[str] = None,
ws_uri: Optional[str] = None,
timeout: int = 30,
body: Optional[Union[str, bytes]] = None,
content_type: Optional[str] = None,
session_timer: Optional[dict] = None,
) -> str
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,
)
bridge
async
¶
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)
unbridge
async
¶
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")
replace_peer
¶
replace_peer(
call_id: str,
target: str,
next_hop: Optional[str] = None,
replace_a_leg: bool = False,
profile: Optional[str] = None,
timeout: int = 30,
number_policy: Optional[str] = None,
format: Optional[str] = None,
) -> bool
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)
terminate
¶
Imperatively end a B2BUA call by its SIP Call-ID.
Unlike call.terminate() (deferred until its handler returns), this
acts immediately and is keyed by SIP Call-ID, so it works from an
out-of-band event callback (@rtpengine.on_dtmf,
@rtpengine.on_media_timeout), a timer, or a normal handler.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call_id
|
str
|
the SIP Call-ID of the call to end. |
required |
reason
|
str
|
free-text hangup reason (RFC 3326 |
'Normal Clearing'
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True if a matching call was found and torn down, False if the |
bool
|
Call-ID is unknown / already gone. Never raises. |
In the mock, records {"call_id", "reason"} on terminates and
returns True. Inspect via siphon.get_b2bua().terminates.
Usage::
@rtpengine.on_dtmf
def on_ivr_dtmf(call_id, from_tag, digit, duration_ms, volume):
if digit == "#":
b2bua.terminate(call_id)
refer
¶
Imperatively transfer a B2BUA call by its SIP Call-ID.
The imperative twin of :meth:Call.refer. Unlike call.refer()
(a deferred call action, honoured after its handler returns), this
acts immediately and is keyed by SIP Call-ID, so it works from an
out-of-band event callback (@rtpengine.on_dtmf, a timer) where
no call object is in scope and deferred actions are no-ops — the
same reason :meth:terminate exists alongside call.terminate().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call_id
|
str
|
the SIP Call-ID of the call to transfer. |
required |
target
|
str
|
the Refer-To URI (transfer destination). |
required |
replaces
|
Optional[dict]
|
optional attended-transfer dict (RFC 3891) with
|
None
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True if a matching call was found and the REFER was |
bool
|
originated, False if the Call-ID is unknown / already gone. |
|
bool
|
Never raises for a missing call. |
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
In the mock, records {"call_id", "target", "replaces"} on
refers and returns True. Inspect via
siphon.get_b2bua().refers.
Usage::
@rtpengine.on_dtmf
def on_ivr_dtmf(call_id, from_tag, digit, duration_ms, volume):
if digit == "*":
b2bua.refer(call_id, "sip:+15550142@example.com")
on_invite
staticmethod
¶
Register handler for new INVITE (new call).
Handler signature: (call) -> None
on_early_media
staticmethod
¶
Register handler for provisional response with SDP (183/180).
Called when the B-leg sends a provisional response containing SDP (early media). Use this to process the SDP through RTPEngine so early media is anchored correctly.
Handler signature: (call, reply) -> None
Example::
@b2bua.on_early_media
async def early_media(call, reply):
await rtpengine.answer(reply)
on_answer
staticmethod
¶
Register handler for call answered (200 OK on B-leg).
Handler signature: (call, reply) -> None
on_failure
staticmethod
¶
Register handler for a call that could not be connected.
Handler signature: (call, code, reason) -> None
Runs once, before the caller hears anything. code is what the call
failed on: the best of its branches' failures (RFC 3261 §16.7), 408
for the ring timeout, 503 when the B-leg INVITE never left, no LCR
carrier was routable, an LCR sequence ended on the ring timeout of a
carrier that never sent a 101-199, or it moved on and none of the
carriers left could be dialled, 500 when @b2bua.on_answer
raised or ended an answered call, 420 ("Bad Extension") when the
caller Require-s an extension the call cannot honour under its
header policy (the default response lists them in Unsupported; a
re-route under a policy that relays the extension goes through). What
the handler leaves on call is carried out:
- nothing, or
call.terminate(): the caller getscode call.reject(code, reason): the caller gets that response instead (3xx-6xx)call.dial(),call.fork(),call.route(): the call is routed again, and this handler runs again if that fails too (at most 10 re-routes per call)call.handover(app): the unanswered call goes to a control appcall.answer(): siphon answers the caller itself
A handler that raises decides nothing, and the call ends with code.
Example::
@b2bua.on_failure
def failed(call, code, reason):
if code in (408, 480, 486):
call.dial("sip:voicemail@198.51.100.20")
on_bye
staticmethod
¶
Register handler for BYE (call ended).
Handler signature: (call, initiator) -> None
initiator is a :class:ByeInitiator with a .side property
("a" or "b").
on_refer
staticmethod
¶
Register handler for REFER (call transfer, RFC 3515).
Handler signature is single-arg (call) -> None. A REFER is a
SIP request, not a response, so there is no reply object —
do NOT write (call, reply) and do NOT call rtpengine.answer()
here. Read the transfer target off :attr:Call.refer_to (and
:attr:Call.refer_replaces for an attended transfer), then decide
with :meth:Call.accept_refer or :meth:Call.reject_refer.
Example::
@b2bua.on_refer
def handle_refer(call):
log.info(f"Transfer requested to {call.refer_to}")
call.accept_refer()
on_cancel
staticmethod
¶
Register handler for a CANCELled call (RFC 3261 §9).
Handler signature: (call) -> None
Fires once, with the Call object, when an unanswered call
(Calling/Ringing) is CANCELled — the teardown that on_failure
(B-leg error) and on_bye (answered call) never cover. A 2xx that
wins the CANCEL/answer glare is ACK+BYE'd by the framework and never
delivers on_answer, so this hook only ever sees a genuinely
abandoned call. Use it to release per-call resources that no BYE will
clear: rtpengine media anchors, QoS sessions.
Example::
@b2bua.on_cancel
async def handle_cancel(call):
await rtpengine.delete(call)
on_route_failure
staticmethod
¶
Register handler for one failed carrier of an LCR sequence.
Handler signature: (call, route, code) -> None
Fires once per failed attempt of a call.route(...) sequential
failover — including the last one, and for every non-2xx a carrier
returns (a definitive 486 as much as a 503) plus a ring
timeout, reported as 408. That is the same set
:attr:~siphon_sdk.call.Call.route_attempts records, so the two can
never disagree; filter on code for whatever you count as the
carrier's fault.
Purely a notification: the failover decision is already made by the
time this runs, and unlike @b2bua.on_answer raising here does not
change the call's outcome. Use it to count a carrier out, alert, or
feed your own health view.
Example::
@b2bua.on_route_failure
def carrier_failed(call, route, code):
if code in (408, 503):
log.warn(f"carrier {route.carrier_id} failed {code}")
proxy.subscribe_state¶
Generic SUBSCRIBE-dialog state (RFC 6665) for any event package, with optional Redis-backed persistence.
Use handle = proxy.subscribe_state.accept(request, expires=seconds) after
authenticating the subscriber and authorizing the event package and resource.
It sends the 200 response with the dialog's To-tag and negotiated Expires. An
unknown in-dialog request receives 481 and returns None. Refresh returns the
same handle without resetting NOTIFY CSeq or event-body version. Immediately
call handle.notify(body=..., content_type=...); for Expires zero, call
handle.terminate(reason="deactivated", body=..., content_type=...) instead.
The notifier tag is available as handle.local_tag. Expiry sends a terminating
NOTIFY automatically; scripts still own package content and change notifications.
Direct subscriptions remember the received peer and transport. Background NOTIFY uses that exact live stream, never another phone sharing the same proxy address; a closed stream fails visibly. The Contact remains the NOTIFY Request-URI. Subscriptions with Record-Route follow their established route set.
Mock of the Rust proxy.subscribe_state namespace.
Used from scripts under test as proxy.subscribe_state.create(request).
Records NOTIFY and terminate invocations on notifies /
terminates lists for test assertions.
accept
¶
Accept or refresh an authorized notifier subscription; body is script-owned.
send
¶
send(
ruri: str,
event: str,
expires: int,
accept: Optional[str] = None,
target_uri: Optional[str] = None,
headers: Optional[dict] = None,
timeout_ms: int = 2000,
) -> MockSubscribeHandle
Mock outbound SUBSCRIBE — records the call and synthesises a dialog.
Tests can assert on the recorded self.sends list to verify a
script originated a SUBSCRIBE with the expected parameters.
find
¶
Mock dialog lookup by tags. Returns the first live dialog
matching all three identity fields, or None.
SubscribeHandle¶
A single subscription dialog returned by proxy.subscribe_state.create(...).
Mock of the Rust SubscribeHandle.
In the mock, NOTIFY / terminate calls are recorded on the parent
MockSubscribeState for test assertions. No real SIP message is
produced.
event_version
property
¶
Current event-package body version (read-only).
Mirrors the Rust SubscribeHandle.event_version — used for
RFC 3680 reginfo / RFC 4235 dialog-info / RFC 4575 conference
bodies that require a monotonic version= attribute.
next_event_version
¶
Atomically increment and return the next event-package body version.
Call before building a NOTIFY body whose monotonicity matters::
version = handle.next_event_version()
body = registrar.reginfo_xml(aor, state="full", version=version)
handle.notify(body=body, content_type="application/reginfo+xml")
refresh
¶
Mock refresh — records the call and updates the dialog's expiry.
Tests can assert on the parent's refreshes list. Raises if
the dialog wasn't created via send() (consistent with the
Rust contract that refresh is only valid on outbound dialogs).