IMS control¶
The namespaces that make SIPhon an IMS core: iFC evaluation (isc), 5G SBI /
N5 policy authorization and Nbsf discovery (sbi), SIP presence (presence),
lawful intercept (li), and the Session Recording Server hooks (srs).
isc namespace¶
Initial Filter Criteria evaluation (3GPP TS 29.228 / IMS Service Control).
A service profile may name shared iFC sets (<SharedIFCSetID>, TS 29.228
Annex B.2) that the S-CSCF holds locally, configured under isc::
They are merged with the profile's own iFCs by priority at evaluation, and
with one configured Cx SAR advertises the SiFC feature so the HSS sends the
ids (TS 29.229 §7.1.1). A diverted request takes the ORIGINATING_CDIV iFCs,
and SessionDescription trigger points read the SDP passed as body:
matches = isc.evaluate(
served_user, request.method, str(request.ruri), headers,
session_case="originating_cdiv",
body=request.body if request.get_header("Content-Type") == "application/sdp" else None,
)
Mock ISC namespace — Initial Filter Criteria evaluation for testing.
Store per-user iFC profiles and evaluate them against requests.
Example::
from siphon_sdk import mock_module
mock_module.install()
from siphon import isc
# Store a profile (in mock, stores raw XML string)
count = isc.store_profile("sip:alice@example.com", ifc_xml)
# Evaluate — returns pre-configured matches
matches = isc.evaluate("sip:alice@example.com", "INVITE",
"sip:bob@example.com", [], "originating")
store_profile
¶
Parse and store an iFC XML profile for an AoR.
A service profile may name shared iFC sets (<SharedIFCSetID>,
TS 29.228 Annex B.2 / Annex E) instead of, or next to, its own iFCs;
each is resolved against isc.shared_ifc_sets in siphon.yaml,
and an id with no local set is logged at warn with the AoR.
In the mock, the XML is stored as-is (no actual parsing) apart from
its SharedIFCSetIDs, which shared_set_ids() returns. Use
set_eval_results() to configure what evaluate() returns.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
aor
|
str
|
Address of Record. |
required |
ifc_xml
|
str
|
Raw iFC XML string. |
required |
Returns:
| Type | Description |
|---|---|
int
|
The number of iFC rules the profile applies: its own plus those |
int
|
of the shared sets it names that the S-CSCF has (always 1 in the |
int
|
mock). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the XML cannot be parsed, or holds a
|
shared_set_ids
¶
The SharedIFCSetIDs the stored profile of an AoR names, for logging.
Returns:
| Type | Description |
|---|---|
list[int]
|
The ids in profile order; empty when it names none or no profile |
list[int]
|
is stored. |
remove_profile
¶
Remove a stored profile.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
aor
|
str
|
Address of Record. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
has_profile
¶
Check if a profile is stored for an AoR.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
aor
|
str
|
Address of Record. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
evaluate
¶
evaluate(
aor: str,
method: str,
ruri: str,
headers: "list[tuple[str, str]]",
session_case: str = "originating",
start_after_priority: Optional[int] = None,
body: "Optional[str | bytes]" = None,
) -> list[dict]
Evaluate iFCs for a request.
The stored profile's own iFCs and those of the shared iFC sets it names are merged by priority.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
aor
|
str
|
Address of Record. |
required |
method
|
str
|
SIP method (e.g. |
required |
ruri
|
str
|
Request-URI string. |
required |
headers
|
'list[tuple[str, str]]'
|
List of (name, value) tuples. |
required |
session_case
|
str
|
|
'originating'
|
start_after_priority
|
Optional[int]
|
Only iFCs with a higher priority, to resume after the AS the request came back from. |
None
|
body
|
'Optional[str | bytes]'
|
The request's SDP ( |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
list[dict]
|
List of dicts with keys: |
|
list[dict]
|
|
|
list[dict]
|
(bool) and |
|
list[dict]
|
The two flags are the iFC's |
|
list[dict]
|
elements of TS 29.228 (empty elements: present means set). When |
|
list[dict]
|
true, the S-CSCF includes the user's REGISTER, and the 200 OK it |
|
list[dict]
|
answered with, in the third-party REGISTER to that AS |
|
list[dict]
|
(TS 24.229 §5.4.1.7). |
|
Example |
S - CSCF
|
: for match in isc.evaluate(aor, "REGISTER", aor, []): wants_request = match["include_register_request"] wants_response = match["include_register_response"] if wants_request or wants_response: log.info(f"{match['server_name']} wants the REGISTER " f"(request={wants_request}, response={wants_response})") |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
In the mock, returns what set_eval_results configured (an empty
list otherwise), without priorities at or below
start_after_priority; every call is recorded on evaluations.
set_eval_results
¶
Configure what evaluate() returns for a given AoR.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
aor
|
str
|
Address of Record. |
required |
results
|
list[dict]
|
List of dicts, each with keys |
required |
Example::
isc.set_eval_results("sip:alice@example.com", [
{"server_name": "sip:as1@example.com", "default_handling": 0,
"service_info": None, "priority": 0},
])
sbi namespace¶
5G Service-Based Interface — N5/Npcf policy authorization plus Nbsf_Management PCF discovery.
PCF callbacks¶
The PCF calls back over HTTP on the address in sbi.notif_listen. Advertise
http://<notif_listen>/sbi/events as notif_uri; the PCF appends the
TS 29.514 suffix for each callback, so one listener serves both.
notifUri is a required attribute of the create (TS 29.514), so
sbi.create_session needs a callback URI on every call, with or without
events. Set it once in the configuration and a script can leave the
argument out; notif_uri= on a call overrides it. With neither, the call
raises ValueError and nothing is sent.
sbi:
notif_listen: "0.0.0.0:8080"
notif_uri: "http://192.0.2.10:8080/sbi/events" # how the PCF reaches notif_listen
| Route | Body (verbatim dict) | Hook |
|---|---|---|
POST /sbi/events/notify |
EventsNotification |
@sbi.on_event |
POST /sbi/events/terminate |
TerminationInfo |
@sbi.on_terminate |
Both answer 204 once the handlers ran (a handler that raises is logged and
still acknowledged), 400 for a body that is not JSON, and 503 when siphon's
Python executor could not take the job, so the PCF knows the callback was not
handled. Any other path is 404.
A termination can arrive for any app session created with notif_uri. Events
only arrive for what the session subscribed to, with
create_session(events=[...], notif_uri=...) or
update_session(events=[...]).
The bare POST /sbi/events, which reached @sbi.on_event for a PCF that posted
to the advertised URI without a suffix, was deprecated in 1.9.0 and removed in
1.10.0. It now answers 404 like any other unknown path. A PCF that appends the
TS 29.514 suffix — which is what the spec defines — is unaffected.
Mock SBI namespace for testing scripts that use from siphon import sbi.
Provides mock N5/Npcf policy authorization methods plus Nbsf_Management
discovery (discover_pcf_binding).
Example::
import asyncio
from siphon_sdk import mock_module
mock_module.install()
from siphon import sbi
# Awaitable, as in siphon, so a synchronous test drives it through
# asyncio.run; an async test just awaits it.
result = asyncio.run(sbi.create_session(sip_call_id="call-1", ue_ipv4="10.0.0.1"))
assert result["authorized"] is True
set_notif_uri
¶
Test helper: act as a node with sbi.notif_uri configured (or,
with None, without it).
create_session
¶
create_session(
af_app_id: str = "IMS Services",
sip_call_id: Optional[str] = None,
supi: Optional[str] = None,
ue_ipv4: Optional[str] = None,
ue_ipv6: Optional[str] = None,
dnn: Optional[str] = None,
notif_uri: Optional[str] = None,
media_components: Optional[list] = None,
pcf_uri: Optional[str] = None,
events: Optional[list[str]] = None,
) -> Optional[dict]
Create an N5 app session for QoS policy authorization.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
af_app_id
|
str
|
AF-Application identifier (default |
'IMS Services'
|
sip_call_id
|
Optional[str]
|
SIP Call-ID of the call, for correlation in siphon's own log. It is not sent to the PCF: TS 29.514 has no such attribute. |
None
|
supi
|
Optional[str]
|
Subscription Permanent Identifier. |
None
|
ue_ipv4
|
Optional[str]
|
UE IPv4 address. |
None
|
ue_ipv6
|
Optional[str]
|
UE IPv6 address. |
None
|
dnn
|
Optional[str]
|
Data Network Name. |
None
|
notif_uri
|
Optional[str]
|
base URI of siphon's PCF callback listener,
|
None
|
media_components
|
Optional[list]
|
list of media-component dicts (same shape as
|
None
|
pcf_uri
|
Optional[str]
|
per-call N5 target — address this session at the given PCF
base URL (e.g. a BSF-discovered |
None
|
events
|
Optional[list[str]]
|
PCF events to subscribe to, by TS 29.514 |
None
|
Returns:
| Type | Description |
|---|---|
Optional[dict]
|
Dict with |
Optional[dict]
|
(the absolute resource URI — persist it and hand it back to |
Optional[dict]
|
|
Optional[dict]
|
teardown), or |
Raises:
| Type | Description |
|---|---|
ValueError
|
neither |
TypeError
|
|
Example::
result = await sbi.create_session(
sip_call_id=request.call_id,
ue_ipv4=request.source_ip,
notif_uri="http://192.0.2.10:8080/sbi/events",
events=["FAILED_RESOURCES_ALLOCATION"],
)
delete_session
async
¶
Delete an N5 app session.
True means the session no longer exists on the PCF: siphon deleted
it (the PCF answered 2xx), or it was already gone (the PCF answered
404). The second is the usual answer to the delete an
@sbi.on_terminate handler makes with termination["resUri"]. In
both cases siphon stops tracking the session, so
siphon_sbi_npcf_app_sessions_active comes back down.
False means the delete failed (a transport error or any other
non-2xx answer); siphon keeps tracking the session.
In this mock an unknown session is treated as the PCF's 404 and
returns True; use set_delete_failure to exercise False.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
The app session id from |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
bool
|
|
Example::
@sbi.on_terminate
async def handle_termination(termination):
if not await sbi.delete_session(termination["resUri"]):
log.warn("app session delete failed; PCF still holds it")
update_session
¶
update_session(
session_id: str,
media_components: Optional[list] = None,
events: Optional[list[str]] = None,
notif_uri: Optional[str] = None,
) -> Optional[dict]
Update an N5 app session (media renegotiation, event subscription).
The modify is a JSON merge patch, so only what you pass is sent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
The app session id to update, or the absolute
|
required |
media_components
|
Optional[list]
|
list of media-component dicts (same shape as
|
None
|
events
|
Optional[list[str]]
|
replace the subscribed PCF events with these (same names
as |
None
|
notif_uri
|
Optional[str]
|
a new callback base for the subscription
( |
None
|
Returns:
| Type | Description |
|---|---|
Optional[dict]
|
Dict with |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
TypeError
|
|
discover_pcf_binding
¶
discover_pcf_binding(
ue_ipv4: Optional[str] = None,
ue_ipv6: Optional[str] = None,
) -> Awaitable[Optional[dict]]
Nbsf_Management discovery — look up the PCF binding for a UE IP.
Returns a binding dict (5G; configure via set_binding), None
when the BSF has no binding (it answers 204, or 404 on some servers;
4G), or raises sbi.BsfError when configured unhealthy via
set_bsf_error.
Exactly one of ue_ipv4 / ue_ipv6 must be supplied.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ue_ipv4
|
Optional[str]
|
UE IPv4 address (the IPsec SA peer). |
None
|
ue_ipv6
|
Optional[str]
|
UE IPv6 address. siphon sends it as the |
None
|
Returns:
| Type | Description |
|---|---|
Awaitable[Optional[dict]]
|
The binding dict (incl. a ready-to-use |
on_event
staticmethod
¶
Register a handler for incoming PCF event notifications (N5).
The handler receives the PCF's EventsNotification document
(TS 29.514 §5.6.2.6) verbatim as a dict — every field is preserved,
so the keys are the exact 3GPP wire names. Use evSubsUri to
correlate the event with the app-session you created, and evNotifs
for the per-event list. Each entry's flows carries medCompN +
fNums (not flow descriptions).
siphon serves the callback on sbi.notif_listen at
POST /sbi/events/notify: advertise
http://<notif_listen>/sbi/events as notif_uri and the PCF
appends /notify. The PCF only sends events you subscribed to with
create_session(events=[...]) or update_session(events=[...]).
Sync and async handlers both work. A handler that raises is logged and
the PCF still gets 204: a retry would hit the same bug. When
siphon's Python executor is saturated and the handler cannot run, the
PCF gets 503 so it knows the notification was not taken.
POST /sbi/events (no suffix) also reaches this hook, for a PCF that
posts to the advertised URI as-is. It is deprecated and goes away in the
next minor release.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fn
|
Any
|
|
required |
Returns:
| Type | Description |
|---|---|
Any
|
|
Example::
@sbi.on_event
def handle_pcf_event(event):
session_events_uri = event.get("evSubsUri")
for notif in event.get("evNotifs", []):
if notif["event"] == "FAILED_RESOURCES_ALLOCATION":
log.warn(f"PCF could not allocate resources: {session_events_uri}")
on_terminate
staticmethod
¶
Register a handler for PCF-initiated app-session termination (N5).
The PCF sends this when the PDU session behind an app session is
released (the N5 counterpart of a Diameter Rx ASR). The handler
receives the PCF's TerminationInfo document (TS 29.514) verbatim as
a dict:
termCause: why, e.g."PDU_SESSION_TERMINATION"or"ALL_SDF_DEACTIVATION".resUri: the app-session resource URI, the same valuecreate_sessionreturned asapp_session_uri. Use it to find the call the session belonged to.
TS 29.514 has the AF acknowledge the termination and then delete the
app session, so a handler normally releases its per-call state and
calls sbi.delete_session(termination["resUri"]). That also drops
siphon's own tracking of the session.
This is a separate hook from on_event on purpose: an event leaves
the session in place, a termination means it is ending. siphon serves
it on sbi.notif_listen at POST /sbi/events/terminate; the PCF
appends /terminate to the notif_uri given to
create_session, so the same http://<notif_listen>/sbi/events
base serves both callbacks. Answers follow on_event: 204 once the
handlers ran (a raising handler is logged), 503 when the executor
could not run them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fn
|
Any
|
|
required |
Returns:
| Type | Description |
|---|---|
Any
|
|
Example::
@sbi.on_terminate
async def handle_termination(termination):
log.warn(f"PCF ended app session: {termination['termCause']}")
await sbi.delete_session(termination["resUri"])
set_authorized
¶
Configure whether create_session returns authorized (test helper).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
authorized
|
bool
|
Whether sessions should be authorized. |
required |
set_binding
¶
Configure what discover_pcf_binding returns (test helper).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
binding
|
Optional[dict]
|
a binding dict (5G case) or |
required |
set_bsf_error
¶
Configure discover_pcf_binding to raise BsfError (test helper).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raise_error
|
bool
|
when True, |
required |
set_delete_failure
¶
Make delete_session fail (test helper).
Stands in for a PCF that answers the delete with an error other than
404, or cannot be reached: delete_session returns False and the
session stays tracked.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fail
|
bool
|
when True, |
required |
BsfError¶
Raised by sbi.discover_pcf_binding(...) when the BSF is unhealthy. A BSF
that has no binding for the address is healthy: it answers 204 (TS 29.521
§4.2.4.2; some servers answer 404) and the lookup returns None.
Bases: RuntimeError
Raised by sbi.discover_pcf_binding() when the BSF is unhealthy
(5xx / timeout / transport / malformed body).
No binding for the UE IP is not a BsfError: the BSF answers 204
(TS 29.521), or 404 on some servers, and the lookup returns None (the
4G UE case). Mirrors the Rust sbi.BsfError exception.
presence namespace¶
SIP presence document publish/lookup and subscription tracking (RFC 3856 / 6665).
Mock presence namespace — SIP presence publish/subscribe for testing.
Manages presence documents and subscriptions in-memory.
Example::
from siphon_sdk import mock_module
mock_module.install()
from siphon import presence
etag = presence.publish("sip:alice@example.com", "<presence/>", expires=3600)
doc = presence.lookup("sip:alice@example.com")
assert doc == "<presence/>"
sub_id = presence.subscribe("sip:bob@example.com", "sip:alice@example.com")
watchers = presence.subscribers("sip:alice@example.com")
assert len(watchers) == 1
Test helper::
from siphon_sdk.mock_module import get_presence
p = get_presence()
p.clear()
Answer the next NOTIFYs as a subscriber would, to test what a script does once a subscription ends::
p.set_notify_response(481) # the subscriber lost the dialog
p.set_notify_response(None) # no answer: Timer F expires
publish
¶
Publish a presence document for a presentity.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
entity
|
str
|
Presentity URI (e.g. |
required |
pidf_xml
|
str
|
PIDF XML body string. |
required |
expires
|
int
|
Document expiry in seconds (default: 3600). |
3600
|
Returns:
| Type | Description |
|---|---|
str
|
An etag string assigned to the published document. |
Example::
etag = presence.publish("sip:alice@example.com",
"<presence><tuple><status><basic>open</basic></status></tuple></presence>")
lookup
¶
Look up the current presence document for a URI.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
entity
|
str
|
Presentity URI to look up. |
required |
Returns:
| Type | Description |
|---|---|
Optional[str]
|
PIDF XML string, or |
subscribe
¶
Subscribe to presence for a resource.
Creates a new subscription and returns its ID.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subscriber
|
str
|
Watcher URI (e.g. |
required |
resource
|
str
|
Presentity URI to watch. |
required |
event
|
str
|
Event package name (default: |
'presence'
|
expires
|
int
|
Subscription duration in seconds (default: 3600). |
3600
|
Returns:
| Type | Description |
|---|---|
str
|
Subscription ID string. |
subscribe_dialog
¶
subscribe_dialog(
subscriber: str,
resource: str,
event: str = "reg",
expires: int = 3600,
call_id: str = "",
from_tag: str = "",
to_tag: str = "",
route_set: Optional[list] = None,
local_uri: Optional[str] = None,
remote_uri: Optional[str] = None,
local_cseq: int = 0,
version: int = 0,
) -> str
Create a subscription with dialog state for in-dialog NOTIFY.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subscriber
|
str
|
Watcher Contact URI from the SUBSCRIBE — the
dialog's remote target. Used for the NOTIFY's Request-URI
and to resolve where to send it; it is not what goes in
|
required |
resource
|
str
|
Presentity URI being watched. |
required |
event
|
str
|
Event package name. |
'reg'
|
expires
|
int
|
Subscription duration in seconds. |
3600
|
call_id
|
str
|
Call-ID from the SUBSCRIBE dialog. |
''
|
from_tag
|
str
|
From-tag from the SUBSCRIBE. |
''
|
to_tag
|
str
|
To-tag from the SUBSCRIBE. |
''
|
route_set
|
Optional[list]
|
Route headers from Record-Route. Each entry is kept
as one |
None
|
local_uri
|
Optional[str]
|
The SUBSCRIBE's To URI — the dialog's local URI,
which RFC 3261 §12.2.1.1 requires in the |
None
|
remote_uri
|
Optional[str]
|
The SUBSCRIBE's From URI — the dialog's remote
URI, required in the |
None
|
local_cseq
|
int
|
The CSeq number of the dialog's last NOTIFY; the next
carries one more. |
0
|
version
|
int
|
The version the next event-state document carries
(RFC 3680 §5.2), handed out by :meth: |
0
|
Both URI arguments default to resource, which is correct for any
package where the subscriber watches an AoR directly (the reg event
package). Supply them for a watcher subscribed to somebody else's
resource, where the remote URI is the watcher's own AoR.
Returns:
| Type | Description |
|---|---|
str
|
Subscription ID string. |
unsubscribe
¶
Unsubscribe by subscription ID.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subscription_id
|
str
|
The subscription ID returned by :meth: |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
refresh
¶
Refresh a subscription's expiry (RFC 6665 §4.4.1 re-SUBSCRIBE).
Resets the subscription timer to expires seconds, keeping the dialog.
Pair with :meth:find_by_dialog to resolve the id from an in-dialog
SUBSCRIBE before refreshing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subscription_id
|
str
|
The subscription ID (from |
required |
expires
|
int
|
New subscription duration in seconds. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
find_by_dialog
¶
Resolve a subscription id from its dialog (Call-ID, From-tag).
An in-dialog SUBSCRIBE (a refresh, or an un-SUBSCRIBE with Expires: 0)
carries the dialog's Call-ID and the subscriber's From-tag but not the
original subscription id. This maps that pair back so a notifier
(e.g. an S-CSCF handling reg-event) can :meth:refresh or
:meth:unsubscribe the right dialog. Only subscriptions created with
:meth:subscribe_dialog (which store dialog state) are findable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
call_id
|
str
|
Call-ID of the in-dialog SUBSCRIBE. |
required |
from_tag
|
str
|
From-tag of the in-dialog SUBSCRIBE (subscriber's tag). |
required |
Returns:
| Type | Description |
|---|---|
Optional[str]
|
The subscription ID string, or |
next_version
¶
The version for the next event-state document on a subscription.
RFC 3680 §5.2: "Versions start at 0, and increment by one for each new document sent to a subscriber. Versions are scoped within a subscription." The store owns one count per subscription; each call returns it and advances it, so take a version only for a document that is sent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subscription_id
|
str
|
The subscription ID. |
required |
Returns:
| Type | Description |
|---|---|
Optional[int]
|
The version (0 for the first document), or |
Optional[int]
|
subscription. A removed subscription's count goes with it. |
Example::
body = registrar.reginfo_xml(aor, state="partial",
version=presence.next_version(sub_id))
await presence.notify(sub_id, body=body,
content_type="application/reginfo+xml")
subscribers
¶
List subscribers (watchers) for a resource.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
resource
|
str
|
Presentity URI to query. |
required |
Returns:
| Type | Description |
|---|---|
list[dict]
|
List of dicts with keys: |
notify
¶
notify(
subscription_id: str,
body: Optional[str] = None,
content_type: Optional[str] = None,
subscription_state: str = "active",
wait_for_response: bool = False,
timeout_ms: int = 2000,
) -> Optional[int]
Send an in-dialog NOTIFY for a subscription. Awaitable.
In the mock, this records the notification for test assertions and
answers it with the status set by :meth:set_notify_response (200
unless set).
When subscription_state indicates a terminated subscription
(RFC 6665 §4.1.3 — bare "terminated" or
"terminated;reason=...") the subscription is also removed from
the store, mirroring the production auto-GC behavior.
The NOTIFY's final response is always acted on, whether or not the
script waits for it. A subscriber that lost the dialog answers 481;
that, any other of the responses RFC 6665 §4.2.2 names (404, 405, 410,
416, 480-485, 489, 501, 604), a 408, or no final response before
Timer F removes the subscription, in the background, so the next
:meth:subscribers no longer lists it. It is counted in
siphon_presence_subscriptions_removed_total{reason}
(notify_rejected / notify_timeout). A refresh that arrives
while the NOTIFY is out keeps the subscription: the subscriber still
holds the dialog. Any other failure (a 5xx, say) only failed the
transaction and keeps it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subscription_id
|
str
|
The subscription ID from |
required |
body
|
Optional[str]
|
Optional body string (reginfo XML, PIDF XML, etc.). |
None
|
content_type
|
Optional[str]
|
Content-Type of the body. |
None
|
subscription_state
|
str
|
Subscription-State header value (default |
'active'
|
wait_for_response
|
bool
|
|
False
|
timeout_ms
|
int
|
How long |
2000
|
Returns:
| Type | Description |
|---|---|
Optional[int]
|
|
Optional[int]
|
( |
Example::
status = await presence.notify(sub_id, body=xml,
content_type="application/reginfo+xml",
wait_for_response=True)
if status == 481:
log.info("watcher gone, subscription removed")
set_notify_response
¶
Answer the NOTIFYs that follow with status (test helper).
None models a subscriber that never answers: Timer F expires.
terminate
¶
terminate(
subscription_id: str,
reason: Optional[str] = None,
body: Optional[str] = None,
content_type: Optional[str] = None,
) -> bool
Send a terminating NOTIFY and remove the subscription (RFC 6665 §4.4.1).
Sends an in-dialog NOTIFY with
Subscription-State: terminated;reason=<reason>, then removes
the subscription's dialog state from the store. Idempotent: a
second call with the same subscription_id returns False.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subscription_id
|
str
|
The subscription ID from |
required |
reason
|
Optional[str]
|
Termination reason per RFC 6665 §4.2.2 — one of
|
None
|
body
|
Optional[str]
|
Optional final body. |
None
|
content_type
|
Optional[str]
|
Content-Type of the body. |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
bool
|
recorded; |
Example::
sub_id = presence.subscribe_dialog(...)
...
await presence.terminate(sub_id, reason="timeout")
parse_reginfo
¶
Parse an RFC 3680 application/reginfo+xml body for tests.
Mirrors the Rust presence.parse_reginfo shape — returns a
dict {"version": int, "state": "full"|"partial",
"registrations": [...]} so tests asserting against script logic
can use the same dict layout the production binary returns.
li namespace¶
Lawful intercept (ETSI X1/X2/X3) and SIPREC recording triggers.
Mock li namespace — lawful intercept operations for testing.
.. note:: Interception is not triggered from a script. siphon matches every SIP message against the warrants the ADMF provisioned over ETSI X1, in the dispatcher, on every leg — a warrant applies whether or not a script calls anything here. This namespace is for visibility (is this call warranted?) and for operator-driven SIPREC recording, which is not a warrant.
``intercept()`` and ``stop_intercept()`` are kept so existing scripts
keep working, but they only **report** whether a warrant matched. If
they still triggered, a script that called them would produce duplicate
IRI records for one event.
Pre-configure targets for testing::
from siphon_sdk.mock_module import get_li
li = get_li()
li.add_target("sip:alice@example.com")
Then in your script::
from siphon import li
if li.is_target(request):
log.info("this call is subject to a warrant")
Test assertions::
li = get_li()
assert li.is_target(request)
assert li.events == [("record", "call-1@example.com")]
is_enabled
property
¶
Whether the LI subsystem is enabled.
In the mock, returns True if _enabled is set and targets
are configured.
task_count
property
¶
How many intercept tasks the ADMF has provisioned over X1.
Read-only: warrants are provisioned by the ADMF, never by a script.
destination_count
property
¶
How many delivery destinations the ADMF has provisioned over X1.
events
property
¶
List of (operation, target_or_call_id) tuples recorded.
Operations: "intercept", "record", "stop_intercept",
"stop_recording".
is_target
¶
Check if a request matches an active intercept target.
Matches From URI, To URI, or RURI against configured targets.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Any
|
The SIP request object. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
intercept
¶
Report whether this request is being intercepted.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Any
|
The SIP request object. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
.. note:: Retained for compatibility. This does not trigger interception — the dispatcher has already emitted the IRI record for any matching message before a script handler runs. Calling it is harmless and changes nothing.
record
¶
Start SIPREC recording for a request or call.
Accepts either a Request (proxy mode) or Call (B2BUA mode).
In B2BUA mode, the dispatcher will start SIPREC recording on answer
using the SRS URI from lawful_intercept.siprec.srs_uri config.
SIPREC is a recording feature, not lawful interception: it produces no X2 record and is not tied to a provisioned warrant.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Any
|
A |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
Example::
@b2bua.on_invite
def on_invite(call):
li.record(call) # B2BUA mode
call.dial("sip:bob@example.com")
@proxy.on_request("INVITE")
def on_invite(request):
li.record(request) # proxy mode
request.relay()
stop_intercept
¶
Report whether this request is being intercepted.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Any
|
The SIP request object. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
.. note:: Retained for compatibility. Session teardown records are emitted by the dispatcher when the dialog ends; this does not emit one.
set_provisioned_counts
¶
Test helper: set what task_count / destination_count report.
Not part of the siphon API — the real counts come from what the ADMF provisioned.
stop_recording
¶
Stop SIPREC recording for a request or call.
Accepts either a Request or Call object.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
Any
|
A |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
add_target
¶
Add a target URI for intercept matching (test helper).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
uri
|
str
|
SIP URI to match against (e.g. |
required |
srs namespace¶
Session Recording Server acceptance hooks (RFC 7866 SIPREC).
Mock srs namespace — Session Recording Server hooks for testing.
Pre-configure accept/reject behavior::
from siphon_sdk.mock_module import get_srs
srs = get_srs()
srs.accept_all = False # reject all recordings
Register handlers as in production::
from siphon import srs
@srs.on_invite
async def on_recording(metadata):
return True
@srs.on_session_end
async def on_recording_end(session):
pass
Inspect events after test::
srs = get_srs()
assert len(srs.sessions) == 1
accept_all
property
writable
¶
Whether mock auto-accepts all recordings (default True).
sessions
property
¶
List of completed recording sessions (for test assertions).
invite_events
property
¶
List of on_invite calls received (for test assertions).
on_invite
¶
Register handler for incoming SIPREC INVITE (recording request).
The handler receives (metadata,) where metadata is a
:class:~siphon_sdk.srs.RecordingMetadata object.
Return True to accept the recording, False to reject (403).
Example::
@srs.on_invite
async def on_recording(metadata):
log.info(f"Recording: {metadata.session_id}")
return True
on_session_end
¶
Register handler for recording session completion.
The handler receives (session,) where session is a
:class:~siphon_sdk.srs.SrsSession object.
Example::
@srs.on_session_end
async def on_recording_end(session):
log.info(f"Recording {session.session_id} done")
record_invite
¶
Test helper: simulate an inbound SIPREC INVITE event.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
Recording session identifier. |
required |
participants
|
list[str] | None
|
List of participant AoRs. |
None
|
record_session_end
¶
record_session_end(
session_id: str,
recording_call_id: str = "",
duration_secs: int = 0,
recording_dir: str | None = None,
) -> None
Test helper: simulate a completed recording session.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
Recording session identifier. |
required |
recording_call_id
|
str
|
Call-ID of the SIPREC dialog. |
''
|
duration_secs
|
int
|
Recording duration in seconds. |
0
|
recording_dir
|
str | None
|
Path where recordings were written. |
None
|
SrsSession¶
A completed recording session.
Completed recording session info.
Passed to @srs.on_session_end handlers after the recording
finishes (BYE from SRC or timeout).
Example::
@srs.on_session_end
async def on_recording_end(session):
log.info(f"Recording {session.session_id} complete")
log.info(f"Duration: {session.duration}s")
if session.recording_dir:
log.info(f"Files in: {session.recording_dir}")
RecordingMetadata¶
Parsed RFC 7866 recording metadata from a SIPREC INVITE.
Parsed RFC 7866 recording metadata from a SIPREC INVITE.
Passed to @srs.on_invite handlers so the script can inspect
participants, streams, and the session ID before accepting/rejecting.
Example::
@srs.on_invite
async def on_recording(metadata):
if any(p.aor == "sip:vip@example.com" for p in metadata.participants):
return True # always record VIP calls
return False # reject others
SrsParticipant¶
A participant in the recorded call.
Attributes:
| Name | Type | Description |
|---|---|---|
participant_id |
str
|
Unique identifier for this participant. |
aor |
str
|
Address of Record (SIP URI, e.g. |
name |
str | None
|
Optional display name. |