From 7144413d0e69378a7ee979940960b2d8f0c4dbd1 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Sun, 29 Mar 2026 18:43:51 +0200 Subject: [PATCH 01/45] Add outbound calling ablity --- README.md | 18 +- tests/sip/test_messages.py | 145 ++++---- tests/sip/test_protocol.py | 5 + tests/sip/test_transactions.py | 316 ++++++++++++++++- tests/test__main.py | 597 +++++++++++++++++++++++++++++++++ tests/test_ai.py | 104 +++++- tests/test_audio.py | 43 +++ voip/__main__.py | 322 ++++++++++++++++-- voip/ai.py | 115 +++++-- voip/audio.py | 28 +- voip/rtp.py | 18 +- voip/sip/messages.py | 74 ++-- voip/sip/protocol.py | 15 +- voip/sip/transactions.py | 219 +++++++++++- voip/stun.py | 23 +- 15 files changed, 1831 insertions(+), 211 deletions(-) create mode 100644 tests/test__main.py diff --git a/README.md b/README.md index 546fd87..ff0859c 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,24 @@ uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe A simple echo server can be started with: -````console ```console uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo -```` +``` + +Each command supports an optional `--dial TARGET` flag to initiate an +outbound call instead of waiting for an inbound one: + +```console +uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo --dial sip:+15551234567@sip.example.com +uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe --dial sip:+15551234567@sip.example.com +uvx 'voip[cli]' sip sips:alice:********@sip.example.com agent --dial sip:+15551234567@sip.example.com --initial-prompt "Hello, how can I help you?" +``` + +To dial a number, say a message, and hang up automatically: + +```console +uvx 'voip[cli]' sip sips:alice:********@sip.example.com say sip:+15551234567@sip.example.com "Your package has arrived." +``` You can also talk to a local agent (needs [Ollama]): diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index f705cb5..8054081 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -2,8 +2,43 @@ import pytest from voip.sdp.messages import SessionDescription -from voip.sip.messages import Dialog, Message, Request, Response -from voip.sip.types import CallerID, SipUri +from voip.sip import messages +from voip.sip.types import SipUri + + +class TestHeaderMap: + def test_init(self): + """Initialize a HeaderMap with a dictionary of headers.""" + headers = messages.SIPHeaderDict( + {"From": "Alice", "Route": "sip:proxy.example.com"} + ) + assert headers["From"] == "Alice" + assert headers["Route"] == "sip:proxy.example.com" + + def test_init__empty(self): + """Initialize an empty HeaderMap.""" + headers = messages.SIPHeaderDict() + assert headers == {} + + def test__str__(self): + """String representation of a HeaderMap.""" + headers = messages.SIPHeaderDict() + headers["From"] = "Alice" + headers.add("Route", "sip:proxy.example.com") + headers.add("Route", "sip:example.com") + assert str(headers) == ( + "From: Alice\r\nRoute: sip:proxy.example.com\r\nRoute: sip:example.com\r\n" + ) + + def test__bytes__(self): + """Byte representation of a HeaderMap.""" + headers = messages.SIPHeaderDict() + headers["From"] = "Alice" + headers.add("Route", "sip:proxy.example.com") + headers.add("Route", "sip:example.com") + assert bytes(headers) == ( + b"From: Alice\r\nRoute: sip:proxy.example.com\r\nRoute: sip:example.com\r\n" + ) class TestMessage: @@ -14,8 +49,8 @@ def test_parse__request(self): b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\r\n" b"\r\n" ) - result = Message.parse(data) - assert isinstance(result, Request) + result = messages.Message.parse(data) + assert isinstance(result, messages.Request) assert result.method == "INVITE" assert result.uri == "sip:bob@biloxi.com" assert result.version == "SIP/2.0" @@ -32,15 +67,15 @@ def test_parse__request__with_sdp_body(self): b"Content-Type: application/sdp\r\n" b"\r\n" + sdp ) - result = Message.parse(data) - assert isinstance(result, Request) + result = messages.Message.parse(data) + assert isinstance(result, messages.Request) assert isinstance(result.body, SessionDescription) def test_parse__request__without_sdp_content_type(self): """Return None body when Content-Type is not application/sdp.""" data = b"INVITE sip:bob@biloxi.com SIP/2.0\r\nContent-Length: 4\r\n\r\ntest" - result = Message.parse(data) - assert isinstance(result, Request) + result = messages.Message.parse(data) + assert isinstance(result, messages.Request) assert result.body is None def test_parse__response(self): @@ -50,8 +85,8 @@ def test_parse__response(self): b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\r\n" b"\r\n" ) - result = Message.parse(data) - assert isinstance(result, Response) + result = messages.Message.parse(data) + assert isinstance(result, messages.Response) assert result.status_code == 200 assert result.phrase == "OK" assert result.version == "SIP/2.0" @@ -64,49 +99,27 @@ def test_parse__response__with_sdp_body(self): """Parse a SIP response with an SDP body from bytes.""" sdp = b"v=0\r\ns=-\r\nt=0 0\r\n" data = b"SIP/2.0 200 OK\r\nContent-Type: application/sdp\r\n\r\n" + sdp - result = Message.parse(data) - assert isinstance(result, Response) + result = messages.Message.parse(data) + assert isinstance(result, messages.Response) assert isinstance(result.body, SessionDescription) def test_parse__roundtrip_request(self): """Round-trip a SIP request through parse and bytes.""" - request = Request( + request = messages.Request( method="REGISTER", uri="sip:registrar.biloxi.com", headers={"From": "sip:bob@biloxi.com"}, ) - assert Message.parse(bytes(request)) == request + assert messages.Message.parse(bytes(request)) == request def test_parse__roundtrip_response(self): """Round-trip a SIP response through parse and bytes.""" - response = Response( + response = messages.Response( status_code=404, phrase="Not Found", headers={"From": "sip:bob@biloxi.com"}, ) - assert Message.parse(bytes(response)) == response - - def test_parse__skips_header_line_without_colon(self): - """Skip header lines that contain no colon separator.""" - data = b"REGISTER sip:example.com SIP/2.0\r\nInvalidHeaderLine\r\n\r\n" - result = Message.parse(data) - assert isinstance(result, Request) - assert "InvalidHeaderLine" not in result.headers - - def test_parse__from_header__is_caller_id(self): - """From header is parsed as a CallerID instance.""" - data = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\nFrom: sip:alice@atlanta.com\r\n\r\n" - ) - result = Message.parse(data) - assert isinstance(result.headers["From"], CallerID) - assert result.headers["From"] == "sip:alice@atlanta.com" - - def test_parse__to_header__is_caller_id(self): - """To header is parsed as a CallerID instance.""" - data = b"INVITE sip:bob@biloxi.com SIP/2.0\r\nTo: sip:bob@biloxi.com\r\n\r\n" - result = Message.parse(data) - assert isinstance(result.headers["To"], CallerID) + assert messages.Message.parse(bytes(response)) == response def test_parse__from_header__roundtrip_preserves_raw_value(self): """str(CallerID) equals the original header string, so serialization is unchanged.""" @@ -115,17 +128,17 @@ def test_parse__from_header__roundtrip_preserves_raw_value(self): b'From: "08001234567" ;tag=abc\r\n' b"\r\n" ) - result = Message.parse(data) + result = messages.Message.parse(data) assert bytes(result) == data def test_parse__raises_value_error_on_invalid_first_line(self): """Raise ValueError when the first line cannot be parsed as a request.""" - with pytest.raises(ValueError, match="Invalid SIP message"): - Message.parse(b"TOOSHORT\r\n\r\n") + with pytest.raises(ValueError, match="Invalid header"): + messages.Message.parse(b"TOOSHORT\r\n\r\n") def test___str____returns_decoded_bytes(self): """Return the string representation of a request as decoded bytes.""" - request = Request( + request = messages.Request( method="REGISTER", uri="sip:registrar.biloxi.com", headers={"From": "sip:bob@biloxi.com"}, @@ -139,7 +152,7 @@ def test_branch__extracts_via_branch_parameter(self): b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" b"\r\n" ) - request = Message.parse(data) + request = messages.Message.parse(data) assert request.branch == "z9hG4bKabc" def test_remote_tag__with_tag(self): @@ -150,7 +163,7 @@ def test_remote_tag__with_tag(self): b"To: sip:bob@biloxi.com;tag=to-tag-1\r\n" b"\r\n" ) - request = Message.parse(data) + request = messages.Message.parse(data) assert request.remote_tag == "to-tag-1" def test_local_tag__with_tag(self): @@ -161,7 +174,7 @@ def test_local_tag__with_tag(self): b"From: sip:alice@atlanta.com;tag=from-tag-1\r\n" b"\r\n" ) - request = Message.parse(data) + request = messages.Message.parse(data) assert request.local_tag == "from-tag-1" def test_sequence__returns_cseq_number(self): @@ -172,14 +185,14 @@ def test_sequence__returns_cseq_number(self): b"CSeq: 42 INVITE\r\n" b"\r\n" ) - request = Message.parse(data) + request = messages.Message.parse(data) assert request.sequence == 42 class TestRequest: def test___bytes__(self): """Serialize a SIP request to bytes.""" - request = Request( + request = messages.Request( method="INVITE", uri="sip:bob@biloxi.com", headers={"Via": "SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds"}, @@ -193,7 +206,7 @@ def test___bytes__(self): def test___bytes____with_sdp_body(self): """Serialize a SIP request with an SDP body to bytes.""" sdp = SessionDescription() - request = Request( + request = messages.Request( method="INVITE", uri="sip:bob@biloxi.com", body=sdp, @@ -204,7 +217,7 @@ def test___bytes____with_sdp_body(self): def test_branch__with_branch(self): """Branch returns the branch parameter from the Via header.""" - request = Request( + request = messages.Request( method="INVITE", uri="sip:bob@biloxi.com", headers={"Via": "SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc123"}, @@ -213,12 +226,12 @@ def test_branch__with_branch(self): def test_from_dialog__merges_dialog_headers(self): """Merge the provided headers with the dialog's headers.""" - dialog = Dialog( + dialog = messages.Dialog( uac=SipUri.parse("sips:alice@example.com"), local_tag="local-tag", remote_tag="remote-tag", ) - request = Request.from_dialog( + request = messages.Request.from_dialog( dialog=dialog, headers={"Via": "SIP/2.0/TLS example.com;branch=z9hG4bK123"}, method="REGISTER", @@ -232,7 +245,7 @@ def test_from_dialog__merges_dialog_headers(self): class TestResponse: def test___bytes__(self): """Serialize a SIP response to bytes.""" - response = Response( + response = messages.Response( status_code=200, phrase="OK", headers={"Via": "SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds"}, @@ -246,7 +259,7 @@ def test___bytes__(self): def test___bytes____with_sdp_body(self): """Serialize a SIP response with an SDP body to bytes.""" sdp = SessionDescription() - response = Response(status_code=200, phrase="OK", body=sdp) + response = messages.Response(status_code=200, phrase="OK", body=sdp) serialized = bytes(response) assert b"Content-Length:" in serialized assert b"v=0" in serialized @@ -254,10 +267,10 @@ def test___bytes____with_sdp_body(self): def test___bytes____with_sdp_body__auto_content_length(self): """Auto-calculate Content-Length when SDP body is present and header is not set.""" sdp = SessionDescription() - response = Response(status_code=200, phrase="OK", body=sdp) + response = messages.Response(status_code=200, phrase="OK", body=sdp) serialized = bytes(response) assert b"Content-Length:" in serialized - parsed = Message.parse(serialized) + parsed = messages.Message.parse(serialized) assert parsed.body is None def test_from_request__with_dialog_remote_tag(self): @@ -271,12 +284,12 @@ def test_from_request__with_dialog_remote_tag(self): b"CSeq: 1 INVITE\r\n" b"\r\n" ) - request = Message.parse(data) - dialog = Dialog( + request = messages.Message.parse(data) + dialog = messages.Dialog( uac=SipUri.parse("sip:alice@atlanta.com"), remote_tag="server-tag", ) - response = Response.from_request( + response = messages.Response.from_request( request, dialog=dialog, status_code=200, phrase="OK" ) assert "server-tag" in str(response.headers["To"]) @@ -292,15 +305,15 @@ def test_from_request__without_dialog(self): b"CSeq: 1 OPTIONS\r\n" b"\r\n" ) - request = Message.parse(data) - response = Response.from_request(request, status_code=200, phrase="OK") + request = messages.Message.parse(data) + response = messages.Response.from_request(request, status_code=200, phrase="OK") assert response.headers["To"] == request.headers["To"] class TestDialog: def test_from_header__contains_local_tag(self): """from_header includes the local_tag parameter.""" - dialog = Dialog( + dialog = messages.Dialog( uac=SipUri.parse("sips:alice@example.com"), local_tag="my-local-tag", ) @@ -308,7 +321,7 @@ def test_from_header__contains_local_tag(self): def test_to_header__without_remote_tag(self): """to_header omits the tag parameter when remote_tag is None.""" - dialog = Dialog( + dialog = messages.Dialog( uac=SipUri.parse("sip:bob@biloxi.com:5060"), remote_tag=None, ) @@ -316,7 +329,7 @@ def test_to_header__without_remote_tag(self): def test_to_header__with_remote_tag(self): """to_header includes the remote_tag parameter.""" - dialog = Dialog( + dialog = messages.Dialog( uac=SipUri.parse("sip:bob@biloxi.com:5060"), remote_tag="their-tag", ) @@ -324,7 +337,7 @@ def test_to_header__with_remote_tag(self): def test_headers__returns_required_keys(self): """Headers property returns From, To, and Call-ID keys.""" - dialog = Dialog(uac=SipUri.parse("sips:alice@example.com")) + dialog = messages.Dialog(uac=SipUri.parse("sips:alice@example.com")) headers = dialog.headers assert "From" in headers assert "To" in headers @@ -341,8 +354,8 @@ def test_from_request__extracts_call_id_and_tags(self): b"CSeq: 1 INVITE\r\n" b"\r\n" ) - request = Message.parse(data) - dialog = Dialog.from_request(request) + request = messages.Message.parse(data) + dialog = messages.Dialog.from_request(request) assert dialog.call_id == "call-99@atlanta.com" assert dialog.local_tag == "from-tag-99" assert dialog.remote_tag is not None diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index a87757f..6d2fc12 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -857,3 +857,8 @@ def test_connection_lost__no_keepalive_task_without_sip_fixture(self, rtp): session.keepalive_task = None session.connection_lost(None) assert session.transport is None + + def test_on_registered__is_noop(self, rtp, fake_transport): + """on_registered base implementation does nothing and returns None.""" + session = self._make_session(rtp, fake_transport) + assert session.on_registered() is None diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py index e14e377..b4f6ad5 100644 --- a/tests/sip/test_transactions.py +++ b/tests/sip/test_transactions.py @@ -406,10 +406,46 @@ def test_digest_response__unsupported_algorithm_raises(self): algorithm="UNKNOWN-ALG", ) + def test_response_received__200_ok__calls_on_registered(self): + """Successful registration invokes sip.on_registered().""" + registered_calls: list[bool] = [] -# --------------------------------------------------------------------------- -# InviteTransaction -# --------------------------------------------------------------------------- + from voip.sip.protocol import SessionInitiationProtocol + + class TrackingSession(SessionInitiationProtocol): + def on_registered(self) -> None: + registered_calls.append(True) + + import ipaddress + + from voip.rtp import RealtimeTransportProtocol + from voip.types import NetworkAddress + + transport = FakeTransport() + mux = RealtimeTransportProtocol() + from voip.sip.types import SipUri + + session = TrackingSession( + aor=SipUri.parse("sips:alice:secret@example.com"), + rtp=mux, + transaction_class=InviteTransaction, + ) + session.transport = transport + session.local_address = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) + session.is_secure = True + tx = RegistrationTransaction(sip=session, method=SIPMethod.REGISTER) + session.transactions[tx.branch] = tx + response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=lt\r\n" + f"To: sip:example.com;tag=rt\r\n" + f"Call-ID: reg-hook@example.com\r\n" + f"CSeq: 1 REGISTER\r\n" + f"\r\n".encode() + ) + tx.response_received(response) + assert registered_calls == [True] class TestInviteTransaction: @@ -580,13 +616,244 @@ def test_answer__with_record_route(self): ok_data = b"".join(transport.sent) assert b"Record-Route:" in ok_data - async def test_make_call__raises_not_implemented(self): - """make_call raises NotImplementedError since it is not yet implemented.""" - sip = create_sip_session() - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - with pytest.raises(NotImplementedError): - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + async def test_make_call__sends_invite(self): + """make_call sends an INVITE request and registers the transaction.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + request = await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + assert any(b"INVITE" in data for data in transport.sent) + assert tx.branch in sip.transactions + assert request.method == SIPMethod.INVITE + + async def test_make_call__sdp_offer_contains_codec(self): + """make_call includes a non-empty SDP offer body in the INVITE.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + sent_data = b"".join(transport.sent) + assert b"application/sdp" in sent_data + assert b"m=audio" in sent_data + + def test_response_received__100_is_noop(self): + """1xx provisional responses are silently ignored.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + sip.transactions[tx.branch] = tx + response = Message.parse( + f"SIP/2.0 100 Trying\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=lt\r\n" + f"To: sip:bob@biloxi.com\r\n" + f"Call-ID: trying-call@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"\r\n".encode() + ) + tx.response_received(response) + assert tx.branch in sip.transactions + + def test_response_received__4xx_removes_transaction(self): + """4xx responses remove the transaction from the registry.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + sip.transactions[tx.branch] = tx + response = Message.parse( + f"SIP/2.0 486 Busy Here\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=lt\r\n" + f"To: sip:bob@biloxi.com;tag=rt\r\n" + f"Call-ID: busy-call@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"\r\n".encode() + ) + tx.response_received(response) + assert tx.branch not in sip.transactions + + async def test_accept_call__sends_ack_on_200_ok(self): + """_accept_call sends an ACK after receiving 200 OK.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + sip.transactions[tx.branch] = tx + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: out-call@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"Contact: \r\n" + f"\r\n".encode() + ) + await tx._accept_call(ok_response) + sent_data = b"".join(transport.sent) + assert b"ACK" in sent_data + + async def test_accept_call__with_sdp_registers_rtp_handler(self): + """_accept_call registers an RTP call handler when remote SDP is present.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: out-sdp@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"Content-Type: application/sdp\r\n" + f"\r\n" + f"v=0\r\n" + f"o=- 1 1 IN IP4 192.0.2.2\r\n" + f"s=-\r\n" + f"c=IN IP4 192.0.2.2\r\n" + f"t=0 0\r\n" + f"m=audio 5004 RTP/AVP 0\r\n" + f"a=rtpmap:0 PCMU/8000\r\n".encode() + ) + await tx._accept_call(ok_response) + assert len(rtp.calls) > 0 + + async def test_accept_call__stores_dialog(self): + """_accept_call stores the dialog in sip.dialogs after 200 OK.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: out-dialog@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"\r\n".encode() + ) + await tx._accept_call(ok_response) + assert len(sip.dialogs) > 0 + + async def test_accept_call__no_pending_call_class_sends_ack(self): + """_accept_call sends ACK even when no pending_call_class is set.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + tx.pending_call_class = None + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: no-class@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"\r\n".encode() + ) + await tx._accept_call(ok_response) + sent_data = b"".join(transport.sent) + assert b"ACK" in sent_data + + async def test_accept_call__sdp_no_connection_uses_peer(self): + """_accept_call falls back to transport peer address when SDP has no c= line.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: no-conn@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"Content-Type: application/sdp\r\n" + f"\r\n" + f"v=0\r\n" + f"o=- 1 1 IN IP4 192.0.2.2\r\n" + f"s=-\r\n" + f"t=0 0\r\n" + f"m=audio 5004 RTP/AVP 0\r\n" + f"a=rtpmap:0 PCMU/8000\r\n".encode() + ) + await tx._accept_call(ok_response) + assert len(rtp.calls) > 0 + + async def test_accept_call__sdp_zero_port_no_rtp_address(self): + """_accept_call registers call with None address when audio port is 0.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: zero-port@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"Content-Type: application/sdp\r\n" + f"\r\n" + f"v=0\r\n" + f"o=- 1 1 IN IP4 192.0.2.2\r\n" + f"s=-\r\n" + f"c=IN IP4 192.0.2.2\r\n" + f"t=0 0\r\n" + f"m=audio 0 RTP/AVP 0\r\n".encode() + ) + await tx._accept_call(ok_response) + assert None in rtp.calls def test_answer__sdp_without_connection_uses_peer_address(self): """Use the transport peer address when SDP has no c= connection line.""" @@ -650,10 +917,33 @@ def test_answer__sdp_with_zero_port_uses_none_rtp_address(self): tx.answer(call_class=CallFixture) assert any(b"200" in data for data in transport.sent) + async def test_accept_call__record_route_adds_route_header(self): + """_accept_call includes a Route header in the ACK when Record-Route is present.""" + import ipaddress -# --------------------------------------------------------------------------- -# RegistrationError -# --------------------------------------------------------------------------- + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: rr-call@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"Contact: \r\n" + f"Record-Route: \r\n" + f"\r\n".encode() + ) + await tx._accept_call(ok_response) + sent_data = b"".join(transport.sent) + assert b"ACK" in sent_data + assert b"Route" in sent_data class TestRegistrationError: diff --git a/tests/test__main.py b/tests/test__main.py new file mode 100644 index 0000000..06cc8e0 --- /dev/null +++ b/tests/test__main.py @@ -0,0 +1,597 @@ +"""Tests for the VoIP CLI (__main__ module).""" + +import asyncio +import ipaddress +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytest.importorskip("voip.__main__") + +from voip.__main__ import ( + ConsoleMessageProtocol, + _connect_sip_once, + _make_outbound_factory, + _parse_dial_target, + voip, +) +from voip.rtp import RealtimeTransportProtocol +from voip.sip.protocol import SessionInitiationProtocol +from voip.sip.transactions import InviteTransaction +from voip.sip.types import SipUri +from voip.types import NetworkAddress + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_fake_sip_protocol() -> SessionInitiationProtocol: + """Return a minimal SIP protocol stub whose disconnected_event is pre-set.""" + mux = RealtimeTransportProtocol() + aor = SipUri.parse("sips:alice:secret@example.com") + protocol = ConsoleMessageProtocol( + aor=aor, + rtp=mux, + transaction_class=InviteTransaction, + ) + protocol.disconnected_event.set() + protocol.local_address = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) + protocol.is_secure = True + return protocol + + +def _fake_transport_get_extra_info(key, default=None): + """Return standard fake transport metadata for SIP sessions.""" + match key: + case "sockname": + return ("127.0.0.1", 5061) + case "peername": + return ("192.0.2.1", 5061) + case "ssl_object": + return object() + case _: + return default + + +# --------------------------------------------------------------------------- +# _connect_sip_once +# --------------------------------------------------------------------------- + + +class TestConnectSipOnce: + async def test_connects_and_returns_after_disconnect(self): + """_connect_sip_once waits for the session to disconnect, then returns.""" + protocol = _make_fake_sip_protocol() + proxy = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) + + with patch.object( + asyncio.get_event_loop(), + "create_connection", + new=AsyncMock(return_value=(MagicMock(), protocol)), + ): + await _connect_sip_once(lambda: protocol, proxy, False, False) + + async def test_tls_creates_ssl_context(self): + """_connect_sip_once creates an SSL context when use_tls is True.""" + import ssl + + protocol = _make_fake_sip_protocol() + proxy = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) + captured: list = [] + + async def fake_connect(factory, *, host, port, ssl=None): + captured.append(ssl) + return MagicMock(), protocol + + loop = asyncio.get_event_loop() + with patch.object(loop, "create_connection", side_effect=fake_connect): + await _connect_sip_once(lambda: protocol, proxy, True, False) + + assert captured + assert isinstance(captured[0], ssl.SSLContext) + + async def test_no_verify_tls_disables_certificate_check(self): + """_connect_sip_once disables cert verification when no_verify_tls is True.""" + import ssl + + protocol = _make_fake_sip_protocol() + proxy = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) + captured: list = [] + + async def fake_connect(factory, *, host, port, ssl=None): + captured.append(ssl) + return MagicMock(), protocol + + loop = asyncio.get_event_loop() + with patch.object(loop, "create_connection", side_effect=fake_connect): + await _connect_sip_once(lambda: protocol, proxy, True, True) + + assert captured + ctx = captured[0] + assert isinstance(ctx, ssl.SSLContext) + assert ctx.check_hostname is False + assert ctx.verify_mode == ssl.CERT_NONE + + +# --------------------------------------------------------------------------- +# ConsoleMessageProtocol +# --------------------------------------------------------------------------- + + +class TestConsoleMessageProtocol: + def test_verbose_0_does_not_print(self, capsys): + """Verbose=0 suppresses all output from pprint.""" + from voip.rtp import RealtimeTransportProtocol + from voip.sip.messages import Request + + mux = RealtimeTransportProtocol() + aor = SipUri.parse("sips:alice:secret@example.com") + proto = ConsoleMessageProtocol( + aor=aor, + rtp=mux, + transaction_class=InviteTransaction, + verbose=0, + ) + request = Request( + method="OPTIONS", + uri="sip:alice@example.com", + headers={ + "Via": "SIP/2.0/TLS 127.0.0.1:5061;branch=z9hG4bKtest", + "From": "sip:alice@example.com;tag=t1", + "To": "sip:alice@example.com", + "Call-ID": "c@test", + "CSeq": "1 OPTIONS", + }, + ) + proto.pprint(request) + captured = capsys.readouterr() + assert captured.out == "" + + def test_verbose_3_prints_message(self, capsys): + """Verbose=3 prints the formatted SIP message with peer address.""" + import dataclasses + + from voip.rtp import RealtimeTransportProtocol + from voip.sip.messages import Request + + mux = RealtimeTransportProtocol() + aor = SipUri.parse("sips:alice:secret@example.com") + proto = ConsoleMessageProtocol( + aor=aor, + rtp=mux, + transaction_class=InviteTransaction, + verbose=3, + ) + + @dataclasses.dataclass + class FakeTransportStub: + def get_extra_info(self, key, default=None): + match key: + case "peername": + return ("192.0.2.1", 5061) + case _: + return default + + proto.transport = FakeTransportStub() + request = Request( + method="OPTIONS", + uri="sip:alice@example.com", + headers={ + "Via": "SIP/2.0/TLS 127.0.0.1:5061;branch=z9hG4bKtest", + "From": "sip:alice@example.com;tag=t1", + "To": "sip:alice@example.com", + "Call-ID": "c2@test", + "CSeq": "1 OPTIONS", + }, + ) + proto.pprint(request) + captured = capsys.readouterr() + assert "192.0.2.1" in captured.out + + def test_verbose_3_prints_message_ipv6(self, capsys): + """Verbose=3 formats IPv6 peer address in brackets.""" + import dataclasses + + from voip.rtp import RealtimeTransportProtocol + from voip.sip.messages import Request + + mux = RealtimeTransportProtocol() + aor = SipUri.parse("sips:alice:secret@example.com") + proto = ConsoleMessageProtocol( + aor=aor, + rtp=mux, + transaction_class=InviteTransaction, + verbose=3, + ) + + @dataclasses.dataclass + class FakeIPv6Transport: + def get_extra_info(self, key, default=None): + match key: + case "peername": + return ("::1", 5061) + case _: + return default + + proto.transport = FakeIPv6Transport() + request = Request( + method="OPTIONS", + uri="sip:alice@example.com", + headers={ + "Via": "SIP/2.0/TLS ::1;branch=z9hG4bKtest6", + "From": "sip:alice@example.com;tag=t2", + "To": "sip:alice@example.com", + "Call-ID": "c3@test", + "CSeq": "1 OPTIONS", + }, + ) + proto.pprint(request) + captured = capsys.readouterr() + assert "[::1]" in captured.out + + def test_verbose_3_no_transport_prints_unknown(self, capsys): + """Verbose=3 prints '[unknown]' when no transport is set.""" + from voip.rtp import RealtimeTransportProtocol + from voip.sip.messages import Request + + mux = RealtimeTransportProtocol() + aor = SipUri.parse("sips:alice:secret@example.com") + proto = ConsoleMessageProtocol( + aor=aor, + rtp=mux, + transaction_class=InviteTransaction, + verbose=3, + ) + proto.transport = None + request = Request( + method="OPTIONS", + uri="sip:alice@example.com", + headers={ + "Via": "SIP/2.0/TLS 127.0.0.1:5061;branch=z9hG4bKtest7", + "From": "sip:alice@example.com;tag=t3", + "To": "sip:alice@example.com", + "Call-ID": "c4@test", + "CSeq": "1 OPTIONS", + }, + ) + proto.pprint(request) + captured = capsys.readouterr() + assert "[unknown]" in captured.out + + +# --------------------------------------------------------------------------- +# _parse_dial_target +# --------------------------------------------------------------------------- + + +class TestParseDialTarget: + def test_none_returns_none(self): + """_parse_dial_target returns None when no --dial option is provided.""" + assert _parse_dial_target(None) is None + + def test_valid_uri_returns_sip_uri(self): + """_parse_dial_target returns a parsed SipUri for a valid SIP URI string.""" + result = _parse_dial_target("sip:bob@biloxi.com") + assert isinstance(result, SipUri) + assert str(result.user) == "bob" + assert str(result.host) == "biloxi.com" + + def test_invalid_uri_raises_bad_parameter(self): + """_parse_dial_target raises click.BadParameter for an invalid SIP URI.""" + import click # noqa: PLC0415 + + with pytest.raises(click.BadParameter): + _parse_dial_target("not-a-sip-uri") + + +# --------------------------------------------------------------------------- +# _make_outbound_factory +# --------------------------------------------------------------------------- + + +class TestMakeOutboundFactory: + def test_factory_creates_protocol_with_dial_target(self): + """Factory produces a protocol whose dial_target matches the target URI.""" + mux = RealtimeTransportProtocol() + aor = SipUri.parse("sips:alice:secret@example.com") + target = SipUri.parse("sip:bob@biloxi.com") + + from voip.audio import EchoCall # noqa: PLC0415 + + factory = _make_outbound_factory( + verbose=0, + aor=aor, + rtp_protocol=mux, + target_uri=target, + call_class=EchoCall, + call_kwargs={}, + ) + proto = factory() + assert proto.dial_target == str(target) + + +# --------------------------------------------------------------------------- +# echo --dial command +# --------------------------------------------------------------------------- + + +class TestEchoDialCommand: + def test_echo_dial__invalid_target_raises_bad_parameter(self): + """Echo --dial raises BadParameter for an invalid SIP URI target.""" + from click.testing import CliRunner # noqa: PLC0415 + + runner = CliRunner() + result = runner.invoke( + voip, + ["sip", "sips:alice:secret@example.com", "echo", "--dial", "not-a-sip-uri"], + ) + assert result.exit_code != 0 + assert "--dial" in result.output + + def test_echo_dial__initiates_outbound_invite(self): + """Echo --dial registers, then sends an INVITE to the target.""" + import dataclasses # noqa: PLC0415 + + sent_data: list[bytes] = [] + + @dataclasses.dataclass + class WritingTransport: + closed: bool = False + + def write(self, data: bytes) -> None: + sent_data.append(data) + + def close(self) -> None: + self.closed = True + + def get_extra_info(self, key, default=None): + return _fake_transport_get_extra_info(key, default) + + transport = WritingTransport() + mux = RealtimeTransportProtocol() + mux.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + + async def fake_connect_rtp(proxy_addr, stun): + return MagicMock(), mux + + async def fake_connect_sip_once(factory, proxy_addr, use_tls, no_verify_tls): + proto = factory() + proto.connection_made(transport) + if proto.keepalive_task: + proto.keepalive_task.cancel() + proto.keepalive_task = None + await _simulate_register_ok(proto, "reg@example.com") + await asyncio.sleep(0) + + _run_dial_command( + fake_connect_rtp, + fake_connect_sip_once, + [ + "sip", + "sips:alice:secret@example.com", + "echo", + "--dial", + "sip:bob@biloxi.com", + ], + ) + + assert any(b"INVITE" in data for data in sent_data) + + def test_echo_dial__bye_received_closes_session(self): + """Echo --dial closes the session when BYE arrives.""" + import dataclasses # noqa: PLC0415 + + proto_ref: list = [] + + @dataclasses.dataclass + class ClosingTransport: + closed: bool = False + sent: list = dataclasses.field(default_factory=list) + + def write(self, data: bytes) -> None: + self.sent.append(data) + + def close(self) -> None: + self.closed = True + if proto_ref: + proto_ref[0].connection_lost(None) + + def get_extra_info(self, key, default=None): + return _fake_transport_get_extra_info(key, default) + + transport = ClosingTransport() + mux = RealtimeTransportProtocol() + mux.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + + async def fake_connect_rtp(proxy_addr, stun): + return MagicMock(), mux + + async def fake_connect_sip_once(factory, proxy_addr, use_tls, no_verify_tls): + proto = factory() + proto_ref.append(proto) + proto.connection_made(transport) + if proto.keepalive_task: + proto.keepalive_task.cancel() + proto.keepalive_task = None + await _simulate_register_ok(proto, "reg2@example.com") + await asyncio.sleep(0) + invite_tx = _find_invite_tx(proto) + if invite_tx is None: + return + await _simulate_invite_ok(invite_tx) + await _simulate_bye(proto) + await proto.disconnected_event.wait() + + _run_dial_command( + fake_connect_rtp, + fake_connect_sip_once, + [ + "sip", + "sips:alice:secret@example.com", + "echo", + "--dial", + "sip:bob@biloxi.com", + ], + ) + + assert transport.closed + + +# --------------------------------------------------------------------------- +# say command +# --------------------------------------------------------------------------- + + +class TestSayCommand: + def test_say__invalid_target_raises_bad_parameter(self): + """Say raises BadParameter for an invalid SIP URI target.""" + from click.testing import CliRunner # noqa: PLC0415 + + runner = CliRunner() + result = runner.invoke( + voip, + [ + "sip", + "sips:alice:secret@example.com", + "say", + "not-a-sip-uri", + "Hello", + ], + ) + assert result.exit_code != 0 + assert "TARGET" in result.output + + def test_say__initiates_outbound_invite(self): + """Say registers and sends an INVITE to the target.""" + import dataclasses # noqa: PLC0415 + + sent_data: list[bytes] = [] + + @dataclasses.dataclass + class WritingTransport: + closed: bool = False + + def write(self, data: bytes) -> None: + sent_data.append(data) + + def close(self) -> None: + self.closed = True + + def get_extra_info(self, key, default=None): + return _fake_transport_get_extra_info(key, default) + + transport = WritingTransport() + mux = RealtimeTransportProtocol() + mux.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + + async def fake_connect_rtp(proxy_addr, stun): + return MagicMock(), mux + + async def fake_connect_sip_once(factory, proxy_addr, use_tls, no_verify_tls): + proto = factory() + proto.connection_made(transport) + if proto.keepalive_task: + proto.keepalive_task.cancel() + proto.keepalive_task = None + await _simulate_register_ok(proto, "reg-say@example.com") + await asyncio.sleep(0) + + _run_dial_command( + fake_connect_rtp, + fake_connect_sip_once, + [ + "sip", + "sips:alice:secret@example.com", + "say", + "sip:bob@biloxi.com", + "Hello!", + ], + ) + + assert any(b"INVITE" in data for data in sent_data) + + +# --------------------------------------------------------------------------- +# Helpers used by TestEchoDialCommand / TestSayCommand +# --------------------------------------------------------------------------- + + +async def _simulate_register_ok(proto, call_id: str) -> None: + """Send a 200 OK REGISTER response to *proto*.""" + from voip.sip.messages import Message + + reg_branch = list(proto.transactions.keys())[0] + proto.response_received( + Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={reg_branch}\r\n" + f"From: sips:alice@example.com;tag=our-tag\r\n" + f"To: sips:example.com;tag=rt\r\n" + f"Call-ID: {call_id}\r\n" + f"CSeq: 1 REGISTER\r\n" + f"\r\n".encode() + ) + ) + + +def _find_invite_tx(proto): + """Return the first outbound InviteTransaction in *proto.transactions*.""" + return next( + (tx for tx in proto.transactions.values() if hasattr(tx, "pending_call_class")), + None, + ) + + +async def _simulate_invite_ok(invite_tx) -> None: + """Send a 200 OK INVITE response and complete _accept_call.""" + from voip.sip.messages import Message + + ok_invite = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={invite_tx.branch}\r\n" + f"From: sips:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: invite2@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"\r\n".encode() + ) + await invite_tx._accept_call(ok_invite) + + +async def _simulate_bye(proto) -> None: + """Deliver a BYE request to *proto*.""" + from voip.sip.messages import Message + + proto.request_received( + Message.parse( + b"BYE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbye999\r\n" + b"From: sip:bob@biloxi.com;tag=callee-tag\r\n" + b"To: sips:alice@example.com;tag=our-tag\r\n" + b"Call-ID: invite2@example.com\r\n" + b"CSeq: 2 BYE\r\n" + b"\r\n" + ) + ) + + +def _run_dial_command( + fake_connect_rtp, + fake_connect_sip_once, + cli_args: list[str], +) -> None: + """Invoke a dial-capable CLI command with patched transport helpers.""" + import voip.__main__ as main_module # noqa: PLC0415 + from click.testing import CliRunner # noqa: PLC0415 + + orig_rtp = main_module._connect_rtp + orig_sip_once = main_module._connect_sip_once + main_module._connect_rtp = fake_connect_rtp + main_module._connect_sip_once = fake_connect_sip_once + try: + result = CliRunner().invoke(voip, cli_args, catch_exceptions=False) + finally: + main_module._connect_rtp = orig_rtp + main_module._connect_sip_once = orig_sip_once + assert result.exit_code == 0 diff --git a/tests/test_ai.py b/tests/test_ai.py index f6b1022..7dfbdc8 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -10,7 +10,7 @@ pytest.importorskip("ollama") pytest.importorskip("pocket_tts") -from voip.ai import AgentCall, TranscribeCall # noqa: E402 +from voip.ai import AgentCall, SayCall, TranscribeCall # noqa: E402 from voip.audio import AudioCall # noqa: E402 from voip.codecs.pcma import PCMA # noqa: E402 from voip.codecs.pcmu import PCMU # noqa: E402 @@ -56,6 +56,7 @@ def make_agent_call( tts_mock: MagicMock, call_class=None, media: MediaDescription | None = None, + **kwargs, ) -> AgentCall: """Return an AgentCall with mocked Whisper model and Pocket TTS model.""" cls = call_class or AgentCall @@ -70,6 +71,7 @@ def make_agent_call( sip=MagicMock(), caller=CallerID("sip:bob@biloxi.com"), media=med, + **kwargs, ) @@ -345,7 +347,8 @@ def test_agent_call__is_whisper_call(self): """AgentCall is a subclass of TranscribeCall.""" assert issubclass(AgentCall, TranscribeCall) - def test_init__loads_tts_model_when_none(self): + @pytest.mark.asyncio + async def test_init__loads_tts_model_when_none(self): """Load the default Pocket TTS model when tts_model is None.""" tts_mock = MagicMock() tts_mock.get_state_for_audio_prompt.return_value = MagicMock() @@ -360,7 +363,8 @@ def test_init__loads_tts_model_when_none(self): tts_cls.load_model.assert_called_once() assert call.tts_model is tts_mock - def test_init__uses_provided_tts_model(self): + @pytest.mark.asyncio + async def test_init__uses_provided_tts_model(self): """Use the provided TTSModel instance instead of loading a new one.""" tts_mock = MagicMock() tts_mock.get_state_for_audio_prompt.return_value = MagicMock() @@ -378,7 +382,8 @@ def test_init__uses_provided_tts_model(self): tts_cls.load_model.assert_not_called() assert call.tts_model is tts_mock - def test_init__loads_voice_state(self): + @pytest.mark.asyncio + async def test_init__loads_voice_state(self): """Get the voice state from the TTS model on init.""" tts_mock = MagicMock() voice_state = MagicMock() @@ -398,23 +403,26 @@ def test_init__loads_voice_state(self): tts_mock.get_state_for_audio_prompt.assert_called_once_with("alba") assert call._voice_state is voice_state - def test_init__initializes_pending_state(self): + @pytest.mark.asyncio + async def test_init__initializes_pending_state(self): """AgentCall starts with an empty response task.""" tts_mock = MagicMock() tts_mock.get_state_for_audio_prompt.return_value = MagicMock() call = make_agent_call(MagicMock(), tts_mock) assert call._response_task is None - def test_init__initializes_chat_history_with_system_prompt(self): + @pytest.mark.asyncio + async def test_init__initializes_chat_history_with_system_prompt(self): """Chat history is seeded with a system prompt mentioning a phone call.""" tts_mock = MagicMock() tts_mock.get_state_for_audio_prompt.return_value = MagicMock() call = make_agent_call(MagicMock(), tts_mock) - assert len(call._messages) == 1 + assert len(call._messages) == 2 assert call._messages[0]["role"] == "system" assert "phone" in call._messages[0]["content"].lower() - def test_transcription_received__ignores_empty_text(self): + @pytest.mark.asyncio + async def test_transcription_received__ignores_empty_text(self): """transcription_received appends empty text and creates a response task.""" tts_mock = MagicMock() tts_mock.get_state_for_audio_prompt.return_value = MagicMock() @@ -427,7 +435,8 @@ def test_transcription_received__ignores_empty_text(self): mock_ct.assert_called_once() assert {"role": "user", "content": ""} in call._messages - def test_transcription_received__buffers_non_empty_text(self): + @pytest.mark.asyncio + async def test_transcription_received__buffers_non_empty_text(self): """transcription_received appends a user message and creates a response task.""" tts_mock = MagicMock() tts_mock.get_state_for_audio_prompt.return_value = MagicMock() @@ -440,7 +449,8 @@ def test_transcription_received__buffers_non_empty_text(self): assert {"role": "user", "content": "hello"} in call._messages mock_ct.assert_called_once() - def test_transcription_received__schedules_response_task(self): + @pytest.mark.asyncio + async def test_transcription_received__schedules_response_task(self): """transcription_received creates and stores a response task.""" tts_mock = MagicMock() tts_mock.get_state_for_audio_prompt.return_value = MagicMock() @@ -453,7 +463,10 @@ def test_transcription_received__schedules_response_task(self): mock_ct.assert_called_once() assert call._response_task is task_mock - def test_transcription_received__cancels_running_task_before_creating_new(self): + @pytest.mark.asyncio + async def test_transcription_received__cancels_running_task_before_creating_new( + self, + ): """transcription_received cancels any existing response task.""" tts_mock = MagicMock() tts_mock.get_state_for_audio_prompt.return_value = MagicMock() @@ -511,7 +524,7 @@ async def test_respond__passes_full_history_to_ollama(self): messages = kwargs.get("messages") or mock_client.chat.call_args[0][0] # First message is the system prompt assert messages[0]["role"] == "system" - assert messages[1] == {"role": "user", "content": "hello"} + assert messages[2] == {"role": "user", "content": "hello"} async def test_respond__raises_exception_on_error(self): """Exceptions from Ollama propagate out of respond().""" @@ -546,3 +559,70 @@ async def test_respond__re_raises_cancelled_error(self): def test_preferred_codecs__opus_is_first(self): """AgentCall prefers Opus as the highest-priority outbound codec.""" assert AgentCall.supported_codecs[0].payload_type == RTPPayloadType.OPUS + + async def test_initial_prompt__schedules_send_speech_on_connect(self): + """AgentCall sends speech immediately when initial_prompt is set on construction.""" + tts_mock = MagicMock() + tts_mock.get_state_for_audio_prompt.return_value = MagicMock() + audio_mock = MagicMock() + audio_mock.numpy.return_value = np.zeros(16000, dtype=np.float32) + tts_mock.generate_audio.return_value = audio_mock + tts_mock.sample_rate = 22050 + + speeches: list[str] = [] + + class CapturingAgentCall(AgentCall): + async def send_speech(self, text: str) -> None: + speeches.append(text) + + make_agent_call( + MagicMock(), + tts_mock, + call_class=CapturingAgentCall, + media=PCMA_MEDIA, + salutation="Hello, how can I help?", + ) + await asyncio.sleep(0) + + assert speeches == ["Hello, how can I help?"] + + @pytest.mark.asyncio + async def test_initial_prompt__empty_does_not_schedule_send_speech(self): + """AgentCall with initial_prompt='' does not create a send_speech task.""" + tts_mock = MagicMock() + tts_mock.get_state_for_audio_prompt.return_value = MagicMock() + call = make_agent_call(MagicMock(), tts_mock) + # No task created means no asyncio activity beyond __post_init__ + assert call.salutation == "Hi." + + +class TestSayCall: + """Tests for SayCall.""" + + def test_say_call__is_audio_call(self): + """SayCall is a subclass of AudioCall.""" + assert issubclass(SayCall, AudioCall) + + def test_say_call__stores_text(self): + """SayCall stores the text to say.""" + tts_mock = MagicMock() + tts_mock.get_state_for_audio_prompt.return_value = MagicMock() + audio_mock = MagicMock() + audio_mock.numpy.return_value = np.zeros(0, dtype=np.float32) + tts_mock.generate_audio.return_value = audio_mock + tts_mock.sample_rate = 8000 + + with ( + patch("voip.ai.TTSModel") as tts_cls, + patch("asyncio.create_task"), + ): + tts_cls.load_model.return_value = tts_mock + call = SayCall( + rtp=MagicMock(), + sip=MagicMock(), + caller=CallerID("sip:bob@biloxi.com"), + media=PCMA_MEDIA, + text="Hello there!", + ) + + assert call.text == "Hello there!" diff --git a/tests/test_audio.py b/tests/test_audio.py index 4a5618a..d41a824 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -466,6 +466,49 @@ async def test_send_audio__preempts_pending_handle(self): assert first_handle.cancelled() + async def test_on_audio_sent__called_when_all_packets_dispatched(self): + """on_audio_sent is invoked once all packets from send_audio are dispatched.""" + call = make_audio_call(media=PCMU_MEDIA) + remote_addr = ("10.0.0.1", 5004) + call.rtp.calls = {remote_addr: call} + received: list[bool] = [] + + original_on_audio_sent = call.on_audio_sent + + def capturing_on_audio_sent(): + received.append(True) + original_on_audio_sent() + + call.on_audio_sent = capturing_on_audio_sent + + with patch.object(call, "send_packet"): + await call.send_audio(np.zeros(160, dtype=np.float32)) + + # The second _dispatch_next_packet fires after rpt_packet_duration (20 ms) + await asyncio.sleep(0.1) + + assert received == [True] + + async def test_on_audio_sent__not_called_while_packets_remain(self): + """on_audio_sent is not called until the last packet is dispatched.""" + call = make_audio_call(media=PCMU_MEDIA) + remote_addr = ("10.0.0.1", 5004) + call.rtp.calls = {remote_addr: call} + received: list[bool] = [] + + def capturing_on_audio_sent(): + received.append(True) + + call.on_audio_sent = capturing_on_audio_sent + + with patch.object(call, "send_packet"): + # Two 160-sample chunks → two packets, second is deferred + await call.send_audio(np.zeros(320, dtype=np.float32)) + + # outbound_handle is still pending — on_audio_sent must not have fired yet + assert call.outbound_handle is not None + assert received == [] + def make_echo_call(**kwargs) -> EchoCall: """Create an EchoCall with mock rtp/sip for unit testing.""" diff --git a/voip/__main__.py b/voip/__main__.py index 6a8f703..48ea0a2 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import asyncio +import collections.abc import dataclasses import ipaddress import logging @@ -7,11 +8,12 @@ import ssl import time +from voip.ai import SayCall from voip.rtp import RealtimeTransportProtocol from voip.sip import messages from voip.sip.protocol import SessionInitiationProtocol from voip.sip.transactions import InviteTransaction -from voip.sip.types import SipUri +from voip.sip.types import SIPMethod, SipUri from voip.types import NetworkAddress try: @@ -174,14 +176,143 @@ async def _connect_sip( backoff_secs = min(backoff_secs * 2, 60) +async def _connect_sip_once( + session_factory: collections.abc.Callable[[], SessionInitiationProtocol], + proxy_addr: NetworkAddress, + use_tls: bool, + no_verify_tls: bool, +) -> None: + """Connect to a SIP proxy exactly once and wait until the session ends. + + Unlike `_connect_sip`, this coroutine does not reconnect after the session + is closed. Use it when a single outbound call should end the process. + + Args: + session_factory: Callable that returns a new + [`SessionInitiationProtocol`][voip.sip.protocol.SessionInitiationProtocol] + instance. + proxy_addr: SIP proxy address as a `NetworkAddress`. + use_tls: Whether to establish a TLS connection. + no_verify_tls: When ``True``, skip TLS certificate verification. + """ + loop = asyncio.get_running_loop() + ssl_context: ssl.SSLContext | None = None + if use_tls: + ssl_context = ssl.create_default_context() + if no_verify_tls: + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + _, protocol = await loop.create_connection( + session_factory, + host=str(proxy_addr[0]), + port=proxy_addr[1], + ssl=ssl_context, + ) + await protocol.disconnected_event.wait() + + +def _make_outbound_factory( + *, + verbose: int, + aor: SipUri, + rtp_protocol: RealtimeTransportProtocol, + target_uri: SipUri, + call_class: type, + call_kwargs: dict, +) -> collections.abc.Callable[[], ConsoleMessageProtocol]: + """Build a single-shot protocol factory that dials TARGET after registration. + + Returns a factory suitable for `_connect_sip_once`. The factory creates a + [`ConsoleMessageProtocol`][voip.__main__.ConsoleMessageProtocol] subclass + that calls `on_registered` to initiate an outbound INVITE and closes the + SIP session when a BYE is received. + + Args: + verbose: Verbosity level forwarded to + [`ConsoleMessageProtocol`][voip.__main__.ConsoleMessageProtocol]. + aor: Local address-of-record. + rtp_protocol: Shared RTP mux. + target_uri: SIP URI to dial. + call_class: Call handler class (e.g. `EchoCall`). + call_kwargs: Extra keyword arguments forwarded to `call_class`. + + Returns: + A zero-argument factory returning a new protocol instance. + """ + target = str(target_uri) + + class OutboundInviteTransaction(InviteTransaction): + def bye_received(self, request: messages.Request) -> None: + super().bye_received(request) + self.sip.close() + + @dataclasses.dataclass(kw_only=True, slots=True) + class OutboundProtocol(ConsoleMessageProtocol): + dial_target: str + + def on_registered(self) -> None: + tx = OutboundInviteTransaction( + sip=self, + method=SIPMethod.INVITE, + cseq=1, + ) + asyncio.create_task( + tx.make_call(self.dial_target, call_class=call_class, **call_kwargs) + ) + + def factory() -> ConsoleMessageProtocol: + return OutboundProtocol( + verbose=verbose, + transaction_class=OutboundInviteTransaction, + aor=aor, + rtp=rtp_protocol, + dial_target=target, + ) + + return factory + + +def _parse_dial_target(dial: str | None) -> SipUri | None: + """Parse and validate a ``--dial TARGET`` CLI value. + + Args: + dial: Raw string from the ``--dial`` option, or ``None`` when the + option is not supplied. + + Returns: + A parsed [`SipUri`][voip.sip.types.SipUri], or ``None`` when *dial* + is ``None``. + + Raises: + click.BadParameter: When *dial* is not a valid SIP URI. + """ + if dial is None: + return None + try: + return SipUri.parse(dial) + except ValueError as exc: + raise click.BadParameter(str(exc), param_hint="--dial") from exc + + @sip.command() +@click.option( + "--dial", + metavar="TARGET", + default=None, + help="Dial TARGET (a SIP URI) instead of waiting for an inbound call.", +) @click.pass_context -def echo(ctx): - """Echo the caller's speech back after they finish speaking.""" +def echo(ctx, dial: str | None): + """Echo the caller's speech back after they finish speaking. + + Without ``--dial``, waits for inbound calls and echoes them. + With ``--dial TARGET``, registers and immediately dials TARGET. + """ from .audio import EchoCall # noqa: PLC0415 obj = ctx.obj aor = obj["aor"] + target_uri = _parse_dial_target(dial) class EchoInviteTransaction(InviteTransaction): def invite_received(self, request: messages.Request) -> None: @@ -193,17 +324,32 @@ async def run(): aor.maddr, obj["stun_server"], ) - await _connect_sip( - lambda: ConsoleMessageProtocol( - verbose=obj.get("verbose", 0), - transaction_class=EchoInviteTransaction, - aor=aor, - rtp=rtp_protocol, - ), - aor.maddr, - aor.transport == "TLS", - obj["no_verify_tls"], - ) + if target_uri is None: + await _connect_sip( + lambda: ConsoleMessageProtocol( + verbose=obj.get("verbose", 0), + transaction_class=EchoInviteTransaction, + aor=aor, + rtp=rtp_protocol, + ), + aor.maddr, + aor.transport == "TLS", + obj["no_verify_tls"], + ) + else: + await _connect_sip_once( + _make_outbound_factory( + verbose=obj.get("verbose", 0), + aor=aor, + rtp_protocol=rtp_protocol, + target_uri=target_uri, + call_class=EchoCall, + call_kwargs={}, + ), + aor.maddr, + aor.transport == "TLS", + obj["no_verify_tls"], + ) try: asyncio.run(run()) @@ -219,15 +365,26 @@ async def run(): show_default=True, help="Whisper model size.", ) +@click.option( + "--dial", + metavar="TARGET", + default=None, + help="Dial TARGET (a SIP URI) instead of waiting for an inbound call.", +) @click.pass_context -def transcribe(ctx, stt_model): - """Transcribe incoming call audio.""" +def transcribe(ctx, stt_model, dial: str | None): + """Transcribe incoming call audio. + + Without ``--dial``, waits for inbound calls and transcribes them. + With ``--dial TARGET``, registers and immediately dials TARGET. + """ from faster_whisper import WhisperModel from .ai import TranscribeCall # noqa: PLC0415 obj = ctx.obj aor = obj["aor"] + target_uri = _parse_dial_target(dial) @dataclasses.dataclass(kw_only=True, slots=True) class TranscribingCall(TranscribeCall): @@ -249,17 +406,32 @@ async def run(): aor.maddr, obj["stun_server"], ) - await _connect_sip( - lambda: ConsoleMessageProtocol( - verbose=obj.get("verbose", 0), - transaction_class=TranscribeInviteTransaction, - aor=aor, - rtp=rtp_protocol, - ), - aor.maddr, - aor.transport == "TLS", - obj["no_verify_tls"], - ) + if target_uri is None: + await _connect_sip( + lambda: ConsoleMessageProtocol( + verbose=obj.get("verbose", 0), + transaction_class=TranscribeInviteTransaction, + aor=aor, + rtp=rtp_protocol, + ), + aor.maddr, + aor.transport == "TLS", + obj["no_verify_tls"], + ) + else: + await _connect_sip_once( + _make_outbound_factory( + verbose=obj.get("verbose", 0), + aor=aor, + rtp_protocol=rtp_protocol, + target_uri=target_uri, + call_class=TranscribingCall, + call_kwargs={"stt_model": WhisperModel(stt_model)}, + ), + aor.maddr, + aor.transport == "TLS", + obj["no_verify_tls"], + ) try: asyncio.run(run()) @@ -299,8 +471,25 @@ async def run(): envvar="LLM_SYSTEM_PROMPT", help=("System prompt for the language model."), ) +@click.option( + "--salutation", + default="Hi!", + envvar="LLM_SALUTATION", + help=( + "Initial message the agent says when the call connects. " + "Works for both inbound and outbound calls." + ), +) +@click.option( + "--dial", + metavar="TARGET", + default=None, + help="Dial TARGET (a SIP URI) instead of waiting for an inbound call.", +) @click.pass_context -def agent(ctx, stt_model, llm_model, voice, system_prompt): +def agent( + ctx, stt_model, llm_model, voice, system_prompt, salutation, dial: str | None +): """Register with a SIP carrier and handle calls with an AI voice agent.""" from faster_whisper import WhisperModel @@ -308,6 +497,7 @@ def agent(ctx, stt_model, llm_model, voice, system_prompt): obj = ctx.obj aor = obj["aor"] + target_uri = _parse_dial_target(dial) @dataclasses.dataclass(kw_only=True, slots=True) class AgentCallWithOutput(AgentCall): @@ -343,19 +533,87 @@ def invite_received(self, request: messages.Request) -> None: llm_model=llm_model, voice=voice, system_prompt=system_prompt, + salutation=salutation, + ) + + async def run(): + _, rtp_protocol = await _connect_rtp( + aor.maddr, + obj["stun_server"], + ) + if target_uri is None: + await _connect_sip( + lambda: ConsoleMessageProtocol( + verbose=obj.get("verbose", 0), + transaction_class=AgentInviteTransaction, + aor=aor, + rtp=rtp_protocol, + ), + aor.maddr, + aor.transport == "TLS", + obj["no_verify_tls"], + ) + else: + await _connect_sip_once( + _make_outbound_factory( + verbose=obj.get("verbose", 0), + aor=aor, + rtp_protocol=rtp_protocol, + target_uri=target_uri, + call_class=AgentCallWithOutput, + call_kwargs={ + "stt_model": WhisperModel(stt_model), + "llm_model": llm_model, + "voice": voice, + "system_prompt": system_prompt, + "initial_prompt": salutation, + }, + ), + aor.maddr, + aor.transport == "TLS", + obj["no_verify_tls"], ) + try: + asyncio.run(run()) + except KeyboardInterrupt: + pass + + +@sip.command() +@click.argument("target") +@click.argument("prompt") +@click.option( + "--voice", + default="marius", + envvar="TTS_VOICE", + show_default=True, + help="Pocket TTS voice name or path to a conditioning audio file.", +) +@click.pass_context +def say(ctx, target: str, prompt: str, voice: str): + """Dial TARGET, say PROMPT using TTS, and hang up.""" + obj = ctx.obj + aor = obj["aor"] + + try: + target_uri = SipUri.parse(target) + except ValueError as exc: + raise click.BadParameter(str(exc), param_hint="TARGET") from exc + async def run(): _, rtp_protocol = await _connect_rtp( aor.maddr, obj["stun_server"], ) - await _connect_sip( - lambda: ConsoleMessageProtocol( + await _connect_sip_once( + _make_outbound_factory( verbose=obj.get("verbose", 0), - transaction_class=AgentInviteTransaction, aor=aor, - rtp=rtp_protocol, + rtp_protocol=rtp_protocol, + target_uri=target_uri, + call_class=SayCall, + call_kwargs={"text": prompt, "voice": voice}, ), aor.maddr, aor.transport == "TLS", diff --git a/voip/ai.py b/voip/ai.py index 66c2571..ef945f8 100644 --- a/voip/ai.py +++ b/voip/ai.py @@ -19,14 +19,14 @@ from faster_whisper import WhisperModel from pocket_tts import TTSModel -from voip.audio import VoiceActivityCall +from voip.audio import AudioCall, VoiceActivityCall if typing.TYPE_CHECKING: import pathlib import torch -__all__ = ["TranscribeCall", "AgentCall"] +__all__ = ["AgentCall", "SayCall", "TranscribeCall"] logger = logging.getLogger(__name__) @@ -96,10 +96,86 @@ def transcription_received(self, text: str) -> None: """ +@dataclasses.dataclass(kw_only=True) +class TTSMixin: + """Mixin that adds Pocket TTS voice synthesis to a call. + + Provides shared `tts_model`, `voice`, and `voice_state` fields along with + the [`send_speech`][voip.ai.TTSMixin.send_speech] method used by both + [`SayCall`][voip.ai.SayCall] and [`AgentCall`][voip.ai.AgentCall]. + + Args: + tts_model: Pre-loaded Pocket TTS model. A new default model is loaded when omitted. + voice: Voice name or conditioning audio accepted by Pocket TTS. + """ + + tts_model: TTSModel = dataclasses.field( + default_factory=lambda: TTSModel.load_model() + ) + voice: pathlib.Path | str | torch.Tensor = dataclasses.field(default="marius") + + _voice_state: dict[str, dict[str, torch.Tensor]] = dataclasses.field( + init=False, repr=False + ) + + def __post_init__(self) -> None: + super().__post_init__() + self._voice_state = self.tts_model.get_state_for_audio_prompt(self.voice) + + async def send_speech(self, text: str) -> None: + """Synthesise `text` and transmit it as outbound RTP audio. + + Args: + text: The message to synthesise and send. + """ + await self.send_audio( + self.resample( + self.tts_model.generate_audio(self._voice_state, text).numpy(), + self.tts_model.sample_rate, + self.codec.sample_rate_hz, + ) + ) + + @dataclasses.dataclass(kw_only=True, slots=True) -class AgentCall(TranscribeCall): +class SayCall(TTSMixin, AudioCall): + """Dial a number, say a message using TTS, and hang up. + + Synthesises `text` with Pocket TTS immediately after the call is + established, sends the audio as outbound RTP, then closes the SIP + session once the last packet has been dispatched. + + Example: + ```python + class MySession(SessionInitiationProtocol): + def on_registered(self) -> None: + tx = InviteTransaction(sip=self, method=SIPMethod.INVITE, cseq=1) + asyncio.create_task( + tx.make_call("sip:bob@biloxi.com", call_class=SayCall, text="Hello!") + ) + ``` + + Args: + text: The message to synthesise and transmit. + tts_model: Pre-loaded Pocket TTS model. A new default model is + loaded when omitted. + voice: Voice name or conditioning audio accepted by Pocket TTS. """ - Respond to caller voice inputs with voice responses. + + text: str + + def __post_init__(self) -> None: + super().__post_init__() + asyncio.create_task(self.send_speech(self.text)) + + def on_audio_sent(self) -> None: + """Close the SIP session after the audio has been fully dispatched.""" + self.sip.close() + + +@dataclasses.dataclass(kw_only=True, slots=True) +class AgentCall(TTSMixin, TranscribeCall): + """Respond to caller voice inputs with voice responses. Uses Ollama to generate responses to transcribed text and Pocket TTS to synthesize voice replies. @@ -109,6 +185,7 @@ class AgentCall(TranscribeCall): llm_model: Ollama model to use for text generation. tts_model: Pocket TTS model to use for voice synthesis. voice: Voice to use for synthesis. + salutation: Opening message sent as soon as the call is established. audio_interrupt_duration: Time you have to talk over the agent to interrupt the outbound audio. """ @@ -118,14 +195,10 @@ class AgentCall(TranscribeCall): " YOU MUST NEVER USE NON-VERBAL CHARACTERS IN YOUR RESPONSES!" ) llm_model: str = dataclasses.field(default="ministral-3") - tts_model: TTSModel = dataclasses.field( - default_factory=lambda: TTSModel.load_model() - ) voice: pathlib.Path | str | torch.Tensor = dataclasses.field(default="azelma") + salutation: str = dataclasses.field(default="Hi.") audio_interrupt_duration: datetime.timedelta = datetime.timedelta(seconds=0.75) - _voice_state: dict[str, dict[str, torch.Tensor]] = dataclasses.field( - init=False, repr=False - ) + _messages: list[dict] = dataclasses.field(init=False, repr=False) _response_task: asyncio.Task | None = dataclasses.field( init=False, repr=False, default=None @@ -148,14 +221,15 @@ class AgentCall(TranscribeCall): def __post_init__(self) -> None: super().__post_init__() - self.tts_model = self.tts_model or TTSModel.load_model() - self._voice_state = self.tts_model.get_state_for_audio_prompt(self.voice) self._messages = [ { "role": "system", "content": self.system_prompt, } ] + if self.salutation: + self._messages.append({"role": "assistant", "content": self.salutation}) + asyncio.create_task(self.send_speech(self.salutation)) def transcription_received(self, text: str) -> None: self.cancel_outbound_audio() @@ -169,23 +243,11 @@ async def respond(self) -> None: model=self.llm_model, messages=self._messages, ) - # clean non-ascii characters from the response for TTS processing if reply := self.emoji_pattern.sub("", response.message.content or ""): self._messages.append({"role": "assistant", "content": reply}) logger.debug("Agent reply: %r", reply) await self.send_speech(reply) - async def send_speech(self, text: str) -> None: - audio = self.tts_model.generate_audio( - self._voice_state, - text, - ) - await self.send_audio( - self.resample( - audio.numpy(), self.tts_model.sample_rate, self.codec.sample_rate_hz - ) - ) - def on_audio_speech(self) -> None: loop = asyncio.get_event_loop() if self._cancel_audio_handle is None: @@ -197,9 +259,6 @@ def on_audio_speech(self) -> None: def on_audio_silence(self) -> None: super().on_audio_silence() - try: + if self._cancel_audio_handle is not None: self._cancel_audio_handle.cancel() - except AttributeError: - pass - else: self._cancel_audio_handle = None diff --git a/voip/audio.py b/voip/audio.py index 25f41c0..bb50fd8 100644 --- a/voip/audio.py +++ b/voip/audio.py @@ -24,7 +24,7 @@ from voip.codecs import RTPCodec from voip.codecs.base import PayloadDecoder from voip.rtp import RTPPacket, Session -from voip.sdp.types import MediaDescription +from voip.sdp.types import MediaDescription, RTPPayloadFormat __all__ = ["AudioCall", "EchoCall", "VoiceActivityCall"] @@ -41,7 +41,7 @@ def generate_ssrc() -> int: return secrets.randbits(32) -@dataclasses.dataclass(slots=True, kw_only=True) +@dataclasses.dataclass(kw_only=True) class AudioCall(Session): """ RTP call handler for audio calls supporting Opus, G.722, PCMA, and PCMU. @@ -141,6 +141,19 @@ def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: f"Supported: {[c.encoding_name for c in cls.supported_codecs]!r}" ) + @classmethod + def sdp_formats(cls) -> list[RTPPayloadFormat]: + """Return all supported payload formats for outbound SDP offers. + + Lists all codecs in `supported_codecs` priority order so the remote + can select the best available codec. + + Returns: + List of [`RTPPayloadFormat`][voip.sdp.types.RTPPayloadFormat] + objects for every codec in `supported_codecs`. + """ + return [codec.to_payload_format() for codec in cls.supported_codecs] + def packet_received(self, packet: RTPPacket, addr: tuple[str, int]) -> None: if packet.payload: asyncio.create_task(self.emit_audio(packet)) @@ -193,6 +206,16 @@ def cancel_outbound_audio(self) -> None: else: self.outbound_handle = None + def on_audio_sent(self) -> None: + """Handle completion of an outbound audio stream. + + Called once the last RTP packet of an outbound stream has been + dispatched (i.e. `outbound_handle` transitions to ``None``). + The base implementation is a no-op. Override in subclasses to + trigger post-audio actions, for example hanging up after + [`SayCall`][voip.ai.SayCall] finishes speaking. + """ + def _dispatch_next_packet( self, packets: Iterator[bytes], @@ -203,6 +226,7 @@ def _dispatch_next_packet( payload = next(packets) except StopIteration: self.outbound_handle = None + self.on_audio_sent() else: self.send_packet(self.next_rtp_packet(payload), remote_addr) duration_seconds = self.rpt_packet_duration.total_seconds() diff --git a/voip/rtp.py b/voip/rtp.py index 5c8e770..e1a5f89 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -13,7 +13,7 @@ import typing from typing import TYPE_CHECKING -from voip.sdp.types import MediaDescription +from voip.sdp.types import MediaDescription, RTPPayloadFormat from voip.srtp import SRTPSession from voip.stun import STUNProtocol from voip.types import ByteSerializableObject, NetworkAddress @@ -168,6 +168,22 @@ def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: "support codec negotiation." ) + @classmethod + def sdp_formats(cls) -> list[RTPPayloadFormat]: + """Return the list of supported payload formats for outbound SDP offers. + + Override in subclasses to advertise codec capabilities. + [`AudioCall`][voip.audio.AudioCall] overrides this to return all + supported codecs in priority order. + + Returns: + List of [`RTPPayloadFormat`][voip.sdp.types.RTPPayloadFormat] + objects describing the supported codecs. + """ + from voip.sdp.types import StaticPayloadType # noqa: PLC0415 + + return [RTPPayloadFormat.from_pt(StaticPayloadType.PCMU.pt)] + @dataclasses.dataclass(kw_only=True, slots=True) class RealtimeTransportProtocol(STUNProtocol): diff --git a/voip/sip/messages.py b/voip/sip/messages.py index cfe549d..906b81c 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -6,6 +6,8 @@ import socket import uuid +from urllib3 import HTTPHeaderDict + from voip.sdp.messages import SessionDescription from ..types import ByteSerializableObject @@ -14,7 +16,26 @@ __all__ = ["Request", "Response", "Message", "Dialog"] #: Headers whose values are parsed as `CallerID` objects. -_CALLER_HEADERS = frozenset({"From", "To"}) +CALLER_IDS_HEADERS = frozenset({"From", "To", "Route", "Record-Route", "Contact"}) + + +class SIPHeaderDict(ByteSerializableObject, HTTPHeaderDict): + """Header map for SIP messages, mapping header names to their values.""" + + def __bytes__(self) -> bytes: + return b"".join(f"{name}: {value}\r\n".encode() for name, value in self.items()) + + @classmethod + def parse(cls, data: bytes) -> SIPHeaderDict: + self = SIPHeaderDict() + for line in data.decode().split("\r\n"): + name, sep, value = line.partition(":") + if not sep: + raise ValueError(f"Invalid header: {data!r}") + name = name.strip() + value = value.strip() + self.add(name, CallerID(value) if name in CALLER_IDS_HEADERS else value) + return self @dataclasses.dataclass(slots=True, kw_only=True) @@ -25,45 +46,41 @@ class Message(ByteSerializableObject, abc.ABC): [RFC 3261 §7]: https://datatracker.ietf.org/doc/html/rfc3261#section-7 """ - headers: dict[str, str | CallerID] = dataclasses.field( - default_factory=dict, repr=False + headers: SIPHeaderDict | dict[str, str | CallerID] = dataclasses.field( + default_factory=SIPHeaderDict, repr=False ) body: SessionDescription | None = dataclasses.field(default=None, repr=False) version: str = "SIP/2.0" + def __post_init__(self): + if not isinstance(self.headers, SIPHeaderDict): + self.headers: SIPHeaderDict = SIPHeaderDict(dict(self.headers)) + @classmethod def parse(cls, data: bytes) -> Request | Response: header_section, _, body = data.partition(b"\r\n\r\n") - lines = header_section.decode().split("\r\n") - first_line, *header_lines = lines - headers = {} - for line in header_lines: - name, sep, value = line.partition(":") - if not sep: - continue - name = name.strip() - value = value.strip() - headers[name] = CallerID(value) if name in _CALLER_HEADERS else value - parts = first_line.split(" ", 2) - if first_line.startswith("SIP/"): + first_line, _, header_section = header_section.partition(b"\r\n") + headers = SIPHeaderDict.parse(header_section) + parts = first_line.split(b" ", 2) + if first_line.startswith(b"SIP/"): version, status_code_str, reason = parts return Response( status_code=int(status_code_str), - phrase=reason, + phrase=reason.decode("ascii"), headers=headers, body=cls._parse_body(headers, body), - version=version, + version=version.decode("ascii"), ) try: method, uri, version = parts except ValueError: raise ValueError(f"Invalid SIP message first line: {data!r}") return Request( - method=method, - uri=uri, + method=method.decode("ascii"), + uri=uri.decode("ascii"), headers=headers, body=cls._parse_body(headers, body), - version=version, + version=version.decode("ascii"), ) @staticmethod @@ -74,14 +91,11 @@ def _parse_body(headers: dict[str, str], body: bytes) -> SessionDescription | No return None def __bytes__(self) -> bytes: - headers = dict(self.headers) - raw_body = bytes(self.body) if self.body is not None else b"" - if raw_body: - headers.setdefault("Content-Length", str(len(raw_body))) - header_lines = "".join( - f"{name}: {value}\r\n" for name, value in headers.items() + if raw_body := bytes(self.body) if self.body is not None else b"": + self.headers["Content-Length"] = str(len(raw_body)) + return b"\r\n".join( + (self._first_line().encode(), bytes(self.headers), raw_body) ) - return f"{self._first_line()}\r\n{header_lines}\r\n".encode() + raw_body @property def branch(self) -> str | None: @@ -92,12 +106,12 @@ def branch(self) -> str | None: @property def remote_tag(self) -> str | None: """To-tag used with From-tag to identify the SIP dialog (RFC 3261 §12.2.2).""" - return self.headers["To"].tag + return CallerID(self.headers["To"]).tag @property def local_tag(self) -> str: """From-tag used with To-tag to identify the SIP dialog (RFC 3261 §12.2.2).""" - return self.headers["From"].tag + return CallerID(self.headers["From"]).tag @property def sequence(self) -> int: @@ -199,7 +213,7 @@ class Dialog: @property def from_header(self) -> str: """The logical sender of a request.""" - return f"{self.uac.scheme}:{self.uac.user}@{socket.gethostname()};tag={self.local_tag}" + return f"{self.uac.scheme}:{self.uac.user}@{self.uac.host};tag={self.local_tag}" @property def to_header(self) -> str: diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index b2f5188..15d768e 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -69,10 +69,6 @@ class MySession(SessionInitiationProtocol): [RFC 3261]: https://datatracker.ietf.org/doc/html/rfc3261 [RFC 3261 §22]: https://datatracker.ietf.org/doc/html/rfc3261#section-22 - Attributes: - VIA_BRANCH_PREFIX: - RFC 3261 §8.1.1.7 Via branch magic cookie (indicates RFC 3261 compliance). - Args: aor: SIP Address of Record (AOR) to register with the carrier, e.g. rtp: Shared RTP mux for call media. When provided, call handlers can register @@ -83,8 +79,6 @@ class MySession(SessionInitiationProtocol): """ - VIA_BRANCH_PREFIX: typing.ClassVar[str] = "z9hG4bK" - aor: types.SipUri rtp: RealtimeTransportProtocol transaction_class: type[InviteTransaction] @@ -309,6 +303,13 @@ def response_received(self, response: Response) -> None: else: tx.response_received(response) + def on_registered(self) -> None: + """Handle successful carrier registration. + + Override in subclasses to initiate outbound calls or start other + post-registration activity. The base implementation is a no-op. + """ + @property def contact(self) -> str: """Return a ``Contact:`` header value for this UA. @@ -332,7 +333,7 @@ def contact(self) -> str: ob_uri_param = ";ob" if self.aor.scheme == "sips": return f"" - tls_param = ";transport=tls" if self.is_secure else "" + tls_param = ";transport=tls" if self.is_secure else ";transport=tcp" return f"" def connection_lost(self, exc: Exception | None) -> None: diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index eb88255..a627d8c 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -1,5 +1,6 @@ """SIP transaction layer (RFC 3261 §17).""" +import asyncio import dataclasses import datetime import hashlib @@ -10,6 +11,7 @@ import typing import uuid +import voip from voip.rtp import Session from voip.sdp.messages import SessionDescription from voip.sdp.types import ( @@ -24,7 +26,7 @@ from ..types import NetworkAddress from . import messages, types -from .messages import Dialog, Request, Response +from .messages import Dialog, Request, Response, SIPHeaderDict from .types import ( CallerID, DigestAlgorithm, @@ -168,6 +170,7 @@ def response_received(self, response: Response) -> None: match response.status_code: case SIPStatus.OK: logger.info("Registration successful") + self.sip.on_registered() return case SIPStatus.UNAUTHORIZED | SIPStatus.PROXY_AUTHENTICATION_REQUIRED: logger.debug( @@ -179,7 +182,7 @@ def response_received(self, response: Response) -> None: ) challenge_key = "Proxy-Authenticate" if is_proxy else "WWW-Authenticate" params = self.parse_auth_challenge( - response.headers.get(challenge_key, "") + response.headers[challenge_key] or "" ) realm = params.get("realm", "") nonce = params.get("nonce", "") @@ -342,6 +345,13 @@ class MySession(SessionInitiationProtocol): [RFC 3261 §17.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.2 """ + pending_call_class: type[Session] | None = dataclasses.field( + default=None, repr=False + ) + pending_call_kwargs: dict[str, typing.Any] = dataclasses.field( + default_factory=dict, repr=False + ) + def invite_received(self, request: Request) -> None: """Handle the incoming call. @@ -575,13 +585,210 @@ async def make_call( ) -> Request: """Initiate an outgoing call to `target`. + Builds an SDP offer using `call_class.sdp_formats`, sends an INVITE, + and registers this transaction to handle the response. When the callee + answers (200 OK), `_accept_call` completes the setup, sends the ACK, + and registers the RTP call handler. + Args: - target: SIP URI of the callee (e.g. ``"sip:bob@example.com"``). + target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). call_class: Session implementation that will be initialized for the call. **call_kwargs: Additional keyword arguments forwarded to the call class constructor. - Raises: - NotImplementedError: Not yet implemented. + Returns: + The INVITE [`Request`][voip.sip.messages.Request] that was sent. + """ + self.pending_call_class = call_class + self.pending_call_kwargs = call_kwargs + + target_uri = types.SipUri.parse(target) + dialog = Dialog(uac=self.sip.aor) + self.dialog = dialog + + rtp_public = self.sip.rtp.public_address + session_id = str(secrets.randbelow(2**32) + 1) + sdp_offer = SessionDescription( + origin=Origin( + username="-", + sess_id=session_id, + sess_version=session_id, + nettype="IN", + addrtype=( + "IP6" if isinstance(rtp_public[0], ipaddress.IPv6Address) else "IP4" + ), + unicast_address=str(rtp_public[0]), + ), + timings=[Timing(start_time=0, stop_time=0)], + connection=ConnectionData( + nettype="IN", + addrtype=( + "IP6" if isinstance(rtp_public[0], ipaddress.IPv6Address) else "IP4" + ), + connection_address=str(rtp_public[0]), + ), + media=[ + MediaDescription( + media="audio", + port=rtp_public[1], + proto="RTP/AVP", + fmt=call_class.sdp_formats(), + attributes=[Attribute(name="sendrecv")], + ) + ], + ) + self.request = Request( + method=SIPMethod.INVITE, + uri=target_uri, + headers={ + "Max-Forwards": "70", + **self.headers, + "From": dialog.from_header, + "To": str(target_uri), + "Contact": self.sip.contact, + "Call-ID": dialog.call_id, + "Route": f"", + "Allow": self.sip.allow_header, + "User-Agent": f"python/voip/{voip.__version__}", + "Content-Type": "application/sdp", + }, + body=sdp_offer, + ) + self.sip.transactions[self.branch] = self + self.sip.send(self.request) + return self.request + + def response_received(self, response: Response) -> None: + """Handle responses to an outbound INVITE. + + Dispatches provisional (1xx), successful (2xx), and failure (4xx–6xx) + responses. On 200 OK the call setup is completed asynchronously via + `_accept_call`. + + Args: + response: The parsed SIP response. + """ + match response.status_code // 100: + case 1: + pass + case 2: + try: + asyncio.get_running_loop().create_task(self._accept_call(response)) + except RuntimeError: + logger.debug( + "response_received called outside of an async context; " + "200 OK will not be processed" + ) + case _: + self.sip.transactions.pop(self.branch, None) + logger.warning( + "Outbound call failed: %s %s", + response.status_code, + response.phrase, + ) + + async def _accept_call(self, response: Response) -> None: + """Complete call setup after a 200 OK is received. + + Negotiates the codec from the remote SDP answer, creates the call + handler, registers it with the RTP mux, updates the dialog, and + sends the ACK. + + Args: + response: The 200 OK SIP response containing the remote SDP answer. """ - raise NotImplementedError("make_call is not yet implemented") + peer = ( + self.sip.transport.get_extra_info("peername") + if self.sip.transport + else None + ) + remote_audio = next( + ( + m + for m in (response.body.media if response.body else []) + if m.media == "audio" + ), + None, + ) + if remote_audio is not None and self.pending_call_class is not None: + negotiated_media = self.pending_call_class.negotiate_codec(remote_audio) + else: + negotiated_media = MediaDescription( + media="audio", + port=0, + proto="RTP/AVP", + fmt=[RTPPayloadFormat.from_pt(0)], + ) + + if self.pending_call_class is not None: + call_handler = self.pending_call_class( + rtp=self.sip.rtp, + sip=self.sip, + caller=CallerID(str(self.sip.aor)), + media=negotiated_media, + srtp=None, + **self.pending_call_kwargs, + ) + if remote_audio is not None and remote_audio.port != 0: + media_connection = remote_audio.connection + session_connection = response.body.connection if response.body else None + connection = media_connection or session_connection + remote_ip = ( + connection.connection_address + if connection is not None + else peer[0] + if peer + else None + ) + remote_rtp_address: NetworkAddress | None = ( + NetworkAddress(remote_ip, remote_audio.port) + if remote_ip is not None + else None + ) + else: + remote_rtp_address = None + self.sip.rtp.register_call(remote_rtp_address, call_handler) + if remote_rtp_address is not None: + self.sip.rtp.send(b"\x00", remote_rtp_address) + + # Update the dialog with remote tag from 200 OK then store it. + # The To-tag in the 200 OK is the callee's tag (remote). The From-tag + # is our original local tag, which must become dialog.remote_tag so + # that subsequent in-dialog BYE lookups (keyed by + # (request.remote_tag, request.local_tag) = (our_tag, callee_tag)) + # resolve correctly via `sip.dialogs[(dialog.remote_tag, dialog.local_tag)]`. + our_tag = response.local_tag + callee_tag = response.remote_tag + self.dialog.remote_tag = our_tag + self.dialog.local_tag = callee_tag + self.sip.dialogs[(our_tag, callee_tag)] = self.dialog + + ack_branch = f"{Transaction.branch_prefix}-{uuid.uuid4()}" + contact = response.headers.get("Contact") + ack_uri = ( + contact.strip("<>").split(";")[0] if contact else str(self.request.uri) + ) + ack_headers: SIPHeaderDict = SIPHeaderDict( + { + "Via": ( + f"SIP/2.0/{self.sip.aor.transport}" + f" {self.sip.rtp.public_address};rport;branch={ack_branch};alias" + ), + "Max-Forwards": "70", + "From": response.headers["From"], + "To": response.headers["To"], + "Call-ID": self.dialog.call_id, + "CSeq": f"{self.cseq} {SIPMethod.ACK}", + "Content-Length": 0, + } + ) + for record_route in response.headers.getlist("Record-Route"): + ack_headers.add("Route", record_route) + self.sip.send( + Request( + method=SIPMethod.ACK, + uri=ack_uri, + headers=ack_headers, + ) + ) + self.sip.transactions.pop(self.branch, None) diff --git a/voip/stun.py b/voip/stun.py index 134b351..4aab386 100644 --- a/voip/stun.py +++ b/voip/stun.py @@ -120,11 +120,10 @@ def packet_received(self, data: bytes, addr: tuple[str, int]) -> None: def connection_made(self, transport: asyncio.DatagramTransport) -> None: self.transport = transport if self.stun_server_address is None: - # IPv6 sockets return a 4-tuple (host, port, flowinfo, scope_id); - # we only need the first two elements. - sockname = transport.get_extra_info("sockname") - host, port = sockname[0], sockname[1] - self.stun_connection_made(transport, (ipaddress.ip_address(host), port)) + host, port = transport.get_extra_info("sockname")[:2] + self.stun_connection_made( + transport, NetworkAddress(host=ipaddress.ip_address(host), port=port) + ) else: self._stun_transaction_id = uuid.uuid4().bytes[:12] self._send_stun_request() @@ -132,7 +131,7 @@ def connection_made(self, transport: asyncio.DatagramTransport) -> None: def stun_connection_made( self, transport: asyncio.DatagramTransport, - addr: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address, int], + addr: NetworkAddress, ) -> None: """Called when the socket is ready and the reachable address is known. @@ -243,10 +242,10 @@ def _parse_stun_response(self, data: bytes) -> None: case STUNAttributeType.MAPPED_ADDRESS: mapped = _parse_address(attribute_value, b"") offset += 4 + ((attribute_len + 3) & ~3) # 4-byte aligned - result = xor_mapped or mapped - if result: - logger.debug("STUN response: %s:%s", *result) - assert self.transport is not None - self.stun_connection_made(self.transport, result) + try: + host, port = xor_mapped or mapped + host = ipaddress.ip_address(host) + except ValueError, TypeError: + logger.exception("No address attribute in STUN response") else: - logger.error("No address attribute in STUN response") + self.stun_connection_made(self.transport, NetworkAddress(host, port)) From f8d7fa78c0cb693dada8193df0518ec571a6796b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:22:27 +0000 Subject: [PATCH 02/45] Add proper BYE hang_up to SIP sessions with dialog cleanup Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/04726956-3a54-4f4f-bb20-68ae2fb4835a Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- docs/cookbook.md | 54 +++++++++++++ docs/sessions.md | 8 +- tests/sip/test_transactions.py | 100 +++++++++++++++++++++++ tests/test_ai.py | 74 +++++++++++++++++ tests/test_rtp.py | 142 ++++++++++++++++++++++++++++++++- voip/ai.py | 26 +++++- voip/rtp.py | 90 +++++++++++++++++++-- voip/sip/messages.py | 13 +++ voip/sip/transactions.py | 15 +++- 9 files changed, 506 insertions(+), 16 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index ef508e3..301fff5 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -198,3 +198,57 @@ session = SIP( rtp_stun_server_address=None, ) ``` + +## Hanging Up a Call + +Every [`Session`][voip.rtp.Session] subclass exposes a +[`hang_up`][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE +request (RFC 3261 §15) to terminate the active dialog and deregisters the RTP +handler. You can call it programmatically from within any call class: + +```python +import asyncio +import ssl + +import numpy as np + +from voip.audio import AudioCall +from voip.sip.protocol import SIP + + +class OneUtteranceCall(AudioCall): + """Hang up as soon as the first voice utterance is received.""" + + async def voice_received(self, audio: np.ndarray) -> None: + await self.hang_up() + self.sip.close() # close the SIP transport after hanging up + + +class MySession(SIP): + def call_received(self, request) -> None: + asyncio.create_task(self.answer(request=request, call_class=OneUtteranceCall)) + + +async def main(): + loop = asyncio.get_running_loop() + await loop.create_connection( + lambda: MySession( + aor="sips:alice@example.com", + username="alice", + password="secret", + ), + host="sip.example.com", + port=5061, + ssl=ssl.create_default_context(), + ) + await asyncio.Future() + + +asyncio.run(main()) +``` + +[`hang_up`][voip.rtp.Session.hang_up] sends the BYE and cleans up the dialog +and RTP handler — it does **not** close the SIP transport so that the same +[`SIP`][voip.sip.protocol.SessionInitiationProtocol] instance can continue +handling other calls. Call `sip.close()` when you also want to tear down the +transport. diff --git a/docs/sessions.md b/docs/sessions.md index 9cc1ef3..5fe3769 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -1,7 +1,11 @@ -# Multimedia Dessions / Call Leg Handlers +# Multimedia Sessions / Call Leg Handlers [Session][voip.rtp.Session] is the base class for all call leg handlers. +## Base Session + +::: voip.rtp.Session + ## Audio Handling ::: voip.audio.AudioCall @@ -15,3 +19,5 @@ ::: voip.ai.TranscribeCall ::: voip.ai.AgentCall + +::: voip.ai.SayCall diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py index b4f6ad5..6ed6e71 100644 --- a/tests/sip/test_transactions.py +++ b/tests/sip/test_transactions.py @@ -591,6 +591,42 @@ def test_answer__stores_dialog(self): tx.answer(call_class=CallFixture) assert len(sip.dialogs) > 0 + def test_answer__dialog_has_local_and_remote_party(self): + """Answer populates dialog.local_party and dialog.remote_party for BYE.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.answer(call_class=CallFixture) + dialog = next(iter(sip.dialogs.values())) + assert dialog.local_party is not None + assert dialog.remote_party is not None + assert "tag=" in dialog.local_party + assert "tag=" in dialog.remote_party + + def test_answer__call_handler_has_dialog(self): + """Answer passes the dialog to the call handler.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + request = Message.parse(INVITE_WITH_SDP_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.answer(call_class=CallFixture) + dialog = next(iter(sip.dialogs.values())) + registered_handler = next(iter(rtp.calls.values())) + assert registered_handler.dialog is dialog + def test_answer__with_record_route(self): """Include Record-Route in 200 OK when present in the INVITE.""" import ipaddress @@ -767,6 +803,70 @@ async def test_accept_call__stores_dialog(self): await tx._accept_call(ok_response) assert len(sip.dialogs) > 0 + async def test_accept_call__dialog_has_bye_fields(self): + """_accept_call populates dialog.local_party, remote_party, and outbound_cseq.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: bye-fields@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"Contact: \r\n" + f"\r\n".encode() + ) + await tx._accept_call(ok_response) + dialog = next(iter(sip.dialogs.values())) + assert dialog.local_party == "sip:alice@example.com;tag=our-tag" + assert dialog.remote_party == "sip:bob@biloxi.com;tag=callee-tag" + assert dialog.outbound_cseq == 2 + assert dialog.remote_contact == "sip:bob@192.0.2.2" + + async def test_accept_call__call_handler_has_dialog(self): + """_accept_call passes the dialog to the call handler.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: bye-fields@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"Contact: \r\n" + f"Content-Type: application/sdp\r\n" + f"\r\n" + f"v=0\r\n" + f"o=- 1 1 IN IP4 192.0.2.2\r\n" + f"s=-\r\n" + f"c=IN IP4 192.0.2.2\r\n" + f"t=0 0\r\n" + f"m=audio 5004 RTP/AVP 0\r\n" + f"a=rtpmap:0 PCMU/8000\r\n".encode() + ) + await tx._accept_call(ok_response) + dialog = next(iter(sip.dialogs.values())) + registered_handler = next(iter(rtp.calls.values())) + assert registered_handler.dialog is dialog + async def test_accept_call__no_pending_call_class_sends_ack(self): """_accept_call sends ACK even when no pending_call_class is set.""" import ipaddress diff --git a/tests/test_ai.py b/tests/test_ai.py index 7dfbdc8..b568459 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -626,3 +626,77 @@ def test_say_call__stores_text(self): ) assert call.text == "Hello there!" + + def test_on_audio_sent__schedules_hang_up(self): + """on_audio_sent schedules hang_up via create_task.""" + tts_mock = MagicMock() + tts_mock.get_state_for_audio_prompt.return_value = MagicMock() + audio_mock = MagicMock() + audio_mock.numpy.return_value = np.zeros(0, dtype=np.float32) + tts_mock.generate_audio.return_value = audio_mock + tts_mock.sample_rate = 8000 + + with ( + patch("voip.ai.TTSModel") as tts_cls, + patch("asyncio.create_task"), + ): + tts_cls.load_model.return_value = tts_mock + call = SayCall( + rtp=MagicMock(), + sip=MagicMock(), + caller=CallerID("sip:bob@biloxi.com"), + media=PCMA_MEDIA, + text="Hello!", + ) + + with patch( + "voip.ai.asyncio.create_task", + side_effect=lambda c: c.close() or MagicMock(), + ) as mock_create_task: + call.on_audio_sent() + + mock_create_task.assert_called_once() + + async def test_hang_up__sends_bye_and_closes_sip(self): + """hang_up sends BYE and closes the SIP transport.""" + from voip.sip.messages import Dialog + from voip.rtp import RealtimeTransportProtocol + + tts_mock = MagicMock() + tts_mock.get_state_for_audio_prompt.return_value = MagicMock() + audio_mock = MagicMock() + audio_mock.numpy.return_value = np.zeros(0, dtype=np.float32) + tts_mock.generate_audio.return_value = audio_mock + tts_mock.sample_rate = 8000 + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} + dialog = Dialog( + call_id="say-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + outbound_cseq=2, + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + + with ( + patch("voip.ai.TTSModel") as tts_cls, + patch("asyncio.create_task"), + ): + tts_cls.load_model.return_value = tts_mock + call = SayCall( + rtp=mock_rtp, + sip=mock_sip, + caller=CallerID("sip:bob@biloxi.com"), + media=PCMA_MEDIA, + text="Hello!", + dialog=dialog, + ) + + await call.hang_up() + mock_sip.send.assert_called_once() + mock_sip.close.assert_called_once() diff --git a/tests/test_rtp.py b/tests/test_rtp.py index acc2acb..2bdff1f 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -541,7 +541,141 @@ def test_negotiate_codec__raises_not_implemented(self): with pytest.raises(NotImplementedError): Session.negotiate_codec(MagicMock()) - async def test_hang_up__raises_not_implemented(self): - """hang_up raises NotImplementedError in the base class.""" - with pytest.raises(NotImplementedError): - await make_call().hang_up() + async def test_hang_up__no_dialog_is_noop(self): + """hang_up is a no-op when no dialog is associated with the call.""" + call = make_call() + await call.hang_up() # must not raise + + async def test_hang_up__sends_bye(self): + """hang_up sends a BYE request when a fully established dialog is present.""" + from voip.sip.messages import Dialog + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} + dialog = Dialog( + call_id="test-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + outbound_cseq=2, + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + await call.hang_up() + mock_sip.send.assert_called_once() + sent = mock_sip.send.call_args[0][0] + assert b"BYE" in bytes(sent) + assert b"sip:bob@192.0.2.2" in bytes(sent) + assert b"CSeq: 2 BYE" in bytes(sent) + + async def test_hang_up__removes_dialog(self): + """hang_up removes the dialog from sip.dialogs.""" + from voip.sip.messages import Dialog + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} + dialog = Dialog( + call_id="test-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + await call.hang_up() + assert (dialog.remote_tag, dialog.local_tag) not in mock_sip.dialogs + + async def test_hang_up__deregisters_rtp_handler(self): + """hang_up unregisters the RTP call handler.""" + from voip.sip.messages import Dialog + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + dialog = Dialog( + call_id="test-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + remote_addr = ("192.0.2.2", 5004) + mock_rtp.calls = {remote_addr: call} + await call.hang_up() + mock_rtp.unregister_call.assert_called_once_with(remote_addr) + + async def test_hang_up__deregisters_wildcard_rtp_handler(self): + """hang_up unregisters a wildcard (addr=None) RTP handler.""" + from voip.sip.messages import Dialog + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + dialog = Dialog( + call_id="test-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + mock_rtp.calls = {None: call} # wildcard registration + await call.hang_up() + mock_rtp.unregister_call.assert_called_once_with(None) + + async def test_hang_up__skips_unregister_when_handler_not_in_rtp(self): + """hang_up does not call unregister_call when handler is not found.""" + from voip.sip.messages import Dialog + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} # call not registered + dialog = Dialog( + call_id="test-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + await call.hang_up() + mock_rtp.unregister_call.assert_not_called() + + async def test_hang_up__missing_local_party_is_noop(self): + """hang_up logs a warning and returns when local_party is not set.""" + from voip.sip.messages import Dialog + + mock_sip = MagicMock() + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} + dialog = Dialog(call_id="test@example.com", remote_contact="sip:bob@192.0.2.2") + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + await call.hang_up() + mock_sip.send.assert_not_called() + + async def test_hang_up__missing_remote_contact_is_noop(self): + """hang_up logs a warning and returns when remote_contact is not set.""" + from voip.sip.messages import Dialog + + mock_sip = MagicMock() + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} + dialog = Dialog( + call_id="test@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + ) + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + await call.hang_up() + mock_sip.send.assert_not_called() diff --git a/voip/ai.py b/voip/ai.py index ef945f8..597b188 100644 --- a/voip/ai.py +++ b/voip/ai.py @@ -142,8 +142,8 @@ class SayCall(TTSMixin, AudioCall): """Dial a number, say a message using TTS, and hang up. Synthesises `text` with Pocket TTS immediately after the call is - established, sends the audio as outbound RTP, then closes the SIP - session once the last packet has been dispatched. + established, sends the audio as outbound RTP, then sends a SIP BYE + and closes the SIP session once the last packet has been dispatched. Example: ```python @@ -155,6 +155,16 @@ def on_registered(self) -> None: ) ``` + To hang up programmatically from any call class, call + [`hang_up`][voip.rtp.Session.hang_up]: + + ```python + class MyCall(AudioCall): + async def voice_received(self, audio: np.ndarray) -> None: + await self.hang_up() + self.sip.close() + ``` + Args: text: The message to synthesise and transmit. tts_model: Pre-loaded Pocket TTS model. A new default model is @@ -169,7 +179,17 @@ def __post_init__(self) -> None: asyncio.create_task(self.send_speech(self.text)) def on_audio_sent(self) -> None: - """Close the SIP session after the audio has been fully dispatched.""" + """Send a SIP BYE and close the session after audio is fully dispatched.""" + asyncio.create_task(self.hang_up()) + + async def hang_up(self) -> None: + """Send BYE and close the SIP transport. + + Extends the base [`hang_up`][voip.rtp.Session.hang_up] by also + closing the SIP transport after the BYE is sent, terminating the + single-shot outbound call session. + """ + await super().hang_up() self.sip.close() diff --git a/voip/rtp.py b/voip/rtp.py index e1a5f89..e02b78b 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -11,6 +11,7 @@ import logging import struct import typing +import uuid from typing import TYPE_CHECKING from voip.sdp.types import MediaDescription, RTPPayloadFormat @@ -19,8 +20,9 @@ from voip.types import ByteSerializableObject, NetworkAddress if TYPE_CHECKING: + from voip.sip.messages import Dialog, Request from voip.sip.protocol import SessionInitiationProtocol - from voip.sip.types import CallerID + from voip.sip.types import CallerID, SIPMethod __all__ = ["RTP", "Session", "RTPPacket", "RTPPayloadType", "RealtimeTransportProtocol"] @@ -106,6 +108,9 @@ class Session: caller: Caller identifier as received in the SIP From header. media: Negotiated SDP media description for this call leg. srtp: Optional SRTP session for encrypting and decrypting media. + dialog: SIP dialog state for this call leg. Set by the transaction + layer after the call is established; used by + [`hang_up`][voip.rtp.Session.hang_up] to send BYE. """ rtp: RealtimeTransportProtocol @@ -113,6 +118,7 @@ class Session: media: MediaDescription caller: CallerID srtp: SRTPSession | None = None + dialog: "Dialog | None" = None def packet_received(self, packet: RTPPacket, addr: NetworkAddress) -> None: """Handle a parsed RTP packet. Override in subclasses to process media. @@ -137,13 +143,85 @@ def send_packet(self, packet: RTPPacket, addr: NetworkAddress) -> None: self.rtp.send(data, addr) async def hang_up(self) -> None: - """Terminate the call by sending a SIP BYE request. + """Terminate the call by sending a SIP BYE request [RFC 3261 §15]. - Raises: - NotImplementedError: Not yet implemented; the call_id and remote - SIP address need to be stored per call to make this work. + Constructs and sends a BYE request for the active dialog, removes the + dialog from the SIP session's registry, and deregisters the RTP handler + so that no further media packets are dispatched. + + The method is a no-op when no dialog is associated with this call (e.g. + before the call is fully established). + + Call `sip.close()` afterwards when you also want to shut down the SIP + transport (e.g. after a single-shot outbound call). + + Example: + Override [`on_audio_sent`][voip.audio.AudioCall.on_audio_sent] to + hang up programmatically once all outbound audio has been sent: + + ```python + from voip.audio import AudioCall + + + class SayAndHangUp(AudioCall): + async def on_audio_sent(self) -> None: + await self.hang_up() + ``` + + [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 """ - raise NotImplementedError("hang_up is not yet implemented") + if self.dialog is None: + return + if self.dialog.local_party is None or self.dialog.remote_party is None: + logger.warning( + "Cannot hang up dialog %s: local or remote party not set", + self.dialog.call_id, + ) + return + + remote_contact = self.dialog.remote_contact + if remote_contact is None: + logger.warning( + "Cannot hang up dialog %s: remote contact not known", + self.dialog.call_id, + ) + return + + from voip.sip.messages import Request # noqa: PLC0415 + from voip.sip.types import SIPMethod # noqa: PLC0415 + + request_uri = str(remote_contact).strip("<>").split(";")[0] + bye_branch = f"z9hG4bK-{uuid.uuid4()}" + bye_request = Request( + method=SIPMethod.BYE, + uri=request_uri, + headers={ + "Via": ( + f"SIP/2.0/{self.sip.aor.transport}" + f" {self.sip.local_address};rport;branch={bye_branch}" + ), + "Max-Forwards": "70", + "From": self.dialog.local_party, + "To": self.dialog.remote_party, + "Call-ID": self.dialog.call_id, + "CSeq": f"{self.dialog.outbound_cseq} {SIPMethod.BYE}", + "Content-Length": "0", + }, + ) + self.sip.send(bye_request) + self.dialog.outbound_cseq += 1 + self.sip.dialogs.pop( + (self.dialog.remote_tag, self.dialog.local_tag), None + ) + # Deregister the RTP handler for this call. Use a sentinel so that a + # wildcard handler registered under addr=None can still be removed. + _not_found = object() + remote_addr = next( + (addr for addr, call in self.rtp.calls.items() if call is self), + _not_found, + ) + if remote_addr is not _not_found: + self.rtp.unregister_call(remote_addr) @classmethod def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: diff --git a/voip/sip/messages.py b/voip/sip/messages.py index 906b81c..74644e0 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -191,6 +191,16 @@ class Dialog: call_id: The Call-ID header value for this dialog. local_tag: The From-header tag parameter value for this dialog. remote_tag: The To-header tag parameter value for this dialog. + local_party: Raw ``From:`` header value (URI + tag) to use in + outbound in-dialog requests such as BYE. Populated by the + transaction layer when the dialog is confirmed. + remote_party: Raw ``To:`` header value (URI + tag) to use in + outbound in-dialog requests such as BYE. Populated by the + transaction layer when the dialog is confirmed. + outbound_cseq: CSeq sequence number for the *next* outbound + in-dialog request. Defaults to ``1`` for the UAS side + (no prior outbound request) and is set to ``cseq + 1`` on the + UAC side after the INVITE is confirmed. """ @@ -205,6 +215,9 @@ class Dialog: remote_tag: str | None = dataclasses.field(default=None, compare=True) remote_contact: SipUri | None = dataclasses.field(default=None, compare=True) route_set: list[SipUri] = dataclasses.field(default_factory=list) + local_party: str | None = dataclasses.field(default=None, compare=False) + remote_party: str | None = dataclasses.field(default=None, compare=False) + outbound_cseq: int = dataclasses.field(default=1, compare=False) created: datetime.datetime = dataclasses.field( init=False, default_factory=datetime.datetime.now diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index a627d8c..9d94265 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -493,12 +493,18 @@ def call_received(self) -> None: use_srtp = negotiated_media.proto == "RTP/SAVP" srtp_session = SRTPSession.generate() if use_srtp else None + dialog = Dialog.from_request(self.request) + dialog.local_party = f"{self.request.headers['To']};tag={dialog.remote_tag}" + dialog.remote_party = str(self.request.headers["From"]) + self.sip.dialogs[dialog.remote_tag, dialog.local_tag] = dialog + call_handler = call_class( rtp=self.sip.rtp, sip=self.sip, caller=caller, media=negotiated_media, srtp=srtp_session, + dialog=dialog, **call_kwargs, ) if remote_audio is not None and remote_audio.port != 0: @@ -529,8 +535,6 @@ def call_received(self) -> None: sdp_media_attributes.append( Attribute(name="crypto", value=srtp_session.sdes_attribute) ) - dialog = Dialog.from_request(self.request) - self.sip.dialogs[dialog.remote_tag, dialog.local_tag] = dialog self.send_response( Response.from_request( request=self.request, @@ -727,6 +731,7 @@ async def _accept_call(self, response: Response) -> None: caller=CallerID(str(self.sip.aor)), media=negotiated_media, srtp=None, + dialog=self.dialog, **self.pending_call_kwargs, ) if remote_audio is not None and remote_audio.port != 0: @@ -768,6 +773,12 @@ async def _accept_call(self, response: Response) -> None: ack_uri = ( contact.strip("<>").split(";")[0] if contact else str(self.request.uri) ) + + # Store BYE-ready dialog state now that dialog tags are finalised. + self.dialog.local_party = str(response.headers["From"]) + self.dialog.remote_party = str(response.headers["To"]) + self.dialog.remote_contact = ack_uri + self.dialog.outbound_cseq = self.cseq + 1 ack_headers: SIPHeaderDict = SIPHeaderDict( { "Via": ( From c94612617caa15998eed5c0769881d4427351ebe Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Mon, 30 Mar 2026 20:32:14 +0200 Subject: [PATCH 03/45] Fix address --- voip/rtp.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/voip/rtp.py b/voip/rtp.py index e02b78b..357fb80 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -20,9 +20,9 @@ from voip.types import ByteSerializableObject, NetworkAddress if TYPE_CHECKING: - from voip.sip.messages import Dialog, Request + from voip.sip.messages import Dialog from voip.sip.protocol import SessionInitiationProtocol - from voip.sip.types import CallerID, SIPMethod + from voip.sip.types import CallerID __all__ = ["RTP", "Session", "RTPPacket", "RTPPayloadType", "RealtimeTransportProtocol"] @@ -118,7 +118,7 @@ class Session: media: MediaDescription caller: CallerID srtp: SRTPSession | None = None - dialog: "Dialog | None" = None + dialog: Dialog | None = None def packet_received(self, packet: RTPPacket, addr: NetworkAddress) -> None: """Handle a parsed RTP packet. Override in subclasses to process media. @@ -198,7 +198,7 @@ async def on_audio_sent(self) -> None: headers={ "Via": ( f"SIP/2.0/{self.sip.aor.transport}" - f" {self.sip.local_address};rport;branch={bye_branch}" + f" {self.sip.rtp.public_address};rport;alias;branch={bye_branch}" ), "Max-Forwards": "70", "From": self.dialog.local_party, @@ -210,9 +210,7 @@ async def on_audio_sent(self) -> None: ) self.sip.send(bye_request) self.dialog.outbound_cseq += 1 - self.sip.dialogs.pop( - (self.dialog.remote_tag, self.dialog.local_tag), None - ) + self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag), None) # Deregister the RTP handler for this call. Use a sentinel so that a # wildcard handler registered under addr=None can still be removed. _not_found = object() From 1d2e345bd8758f7023be70faf36e560d95cdef77 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:33:02 +0000 Subject: [PATCH 04/45] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/cookbook.md | 12 ++++++------ tests/test_ai.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index 301fff5..1d3327d 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -201,10 +201,10 @@ session = SIP( ## Hanging Up a Call -Every [`Session`][voip.rtp.Session] subclass exposes a -[`hang_up`][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE +Every \[`Session`\][voip.rtp.Session] subclass exposes a +\[`hang_up`\][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE request (RFC 3261 §15) to terminate the active dialog and deregisters the RTP -handler. You can call it programmatically from within any call class: +handler. You can call it programmatically from within any call class: ```python import asyncio @@ -247,8 +247,8 @@ async def main(): asyncio.run(main()) ``` -[`hang_up`][voip.rtp.Session.hang_up] sends the BYE and cleans up the dialog +\[`hang_up`\][voip.rtp.Session.hang_up] sends the BYE and cleans up the dialog and RTP handler — it does **not** close the SIP transport so that the same -[`SIP`][voip.sip.protocol.SessionInitiationProtocol] instance can continue -handling other calls. Call `sip.close()` when you also want to tear down the +\[`SIP`\][voip.sip.protocol.SessionInitiationProtocol] instance can continue +handling other calls. Call `sip.close()` when you also want to tear down the transport. diff --git a/tests/test_ai.py b/tests/test_ai.py index b568459..749c286 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -659,8 +659,8 @@ def test_on_audio_sent__schedules_hang_up(self): async def test_hang_up__sends_bye_and_closes_sip(self): """hang_up sends BYE and closes the SIP transport.""" - from voip.sip.messages import Dialog from voip.rtp import RealtimeTransportProtocol + from voip.sip.messages import Dialog tts_mock = MagicMock() tts_mock.get_state_for_audio_prompt.return_value = MagicMock() From 0370426b896750d2dcc2894510cc7a12242b0c68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:41:15 +0000 Subject: [PATCH 05/45] =?UTF-8?q?Add=20ByeTransaction=20to=20acknowledge?= =?UTF-8?q?=20outbound=20BYE=20(RFC=203261=20=C2=A717.1.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/395fc9eb-5d49-4792-8461-898f79412750 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- tests/sip/test_transactions.py | 45 +++++++++++++++++++++++ tests/test_rtp.py | 65 +++++++++++++++++++++++++++++++++- voip/rtp.py | 11 ++++-- voip/sip/__init__.py | 3 +- voip/sip/transactions.py | 34 ++++++++++++++++++ 5 files changed, 154 insertions(+), 4 deletions(-) diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py index 6ed6e71..299e4d1 100644 --- a/tests/sip/test_transactions.py +++ b/tests/sip/test_transactions.py @@ -5,6 +5,7 @@ from voip.sip.exceptions import RegistrationError from voip.sip.messages import Dialog, Message, Response from voip.sip.transactions import ( + ByeTransaction, InviteTransaction, RegistrationTransaction, ) @@ -1046,6 +1047,50 @@ async def test_accept_call__record_route_adds_route_header(self): assert b"Route" in sent_data +class TestByeTransaction: + def test_bye_transaction__has_default_cseq(self): + """ByeTransaction.cseq defaults to 1.""" + sip = create_sip_session() + tx = ByeTransaction(sip=sip, method=SIPMethod.BYE) + assert tx.cseq == 1 + + def test_response_received__removes_transaction_on_200(self): + """response_received removes the transaction when 200 OK is received.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + tx = ByeTransaction(sip=sip, method=SIPMethod.BYE, cseq=2) + sip.transactions[tx.branch] = tx + response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS 127.0.0.1:5061;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: bye-call@example.com\r\n" + f"CSeq: 2 BYE\r\n" + f"\r\n".encode() + ) + tx.response_received(response) + assert tx.branch not in sip.transactions + + def test_response_received__ignores_provisional_response(self): + """response_received leaves the transaction in place for 1xx responses.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + tx = ByeTransaction(sip=sip, method=SIPMethod.BYE, cseq=2) + sip.transactions[tx.branch] = tx + response = Message.parse( + f"SIP/2.0 100 Trying\r\n" + f"Via: SIP/2.0/TLS 127.0.0.1:5061;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com\r\n" + f"Call-ID: bye-call@example.com\r\n" + f"CSeq: 2 BYE\r\n" + f"\r\n".encode() + ) + tx.response_received(response) + assert tx.branch in sip.transactions + + class TestRegistrationError: def test_is_exception(self): """RegistrationError is a subclass of Exception.""" diff --git a/tests/test_rtp.py b/tests/test_rtp.py index 2bdff1f..880beef 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -563,6 +563,7 @@ async def test_hang_up__sends_bye(self): outbound_cseq=2, ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + mock_sip.transactions = {} call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) await call.hang_up() mock_sip.send.assert_called_once() @@ -571,7 +572,69 @@ async def test_hang_up__sends_bye(self): assert b"sip:bob@192.0.2.2" in bytes(sent) assert b"CSeq: 2 BYE" in bytes(sent) - async def test_hang_up__removes_dialog(self): + async def test_hang_up__registers_bye_transaction(self): + """hang_up registers a ByeTransaction to handle the 200 OK acknowledgment.""" + from voip.sip.messages import Dialog + from voip.sip.transactions import ByeTransaction + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} + dialog = Dialog( + call_id="test-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + outbound_cseq=2, + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + transactions: dict = {} + mock_sip.transactions = transactions + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + await call.hang_up() + assert len(transactions) == 1 + (tx,) = transactions.values() + assert isinstance(tx, ByeTransaction) + assert tx.cseq == 2 + + async def test_hang_up__bye_transaction_cleaned_up_on_200(self): + """ByeTransaction removes itself from sip.transactions when 200 OK arrives.""" + from voip.sip.messages import Dialog, Message + from voip.sip.transactions import ByeTransaction + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} + dialog = Dialog( + call_id="test-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + outbound_cseq=2, + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + transactions: dict = {} + mock_sip.transactions = transactions + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + await call.hang_up() + (tx,) = transactions.values() + ok_response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS 192.0.2.1:5061;rport;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=our-tag\r\n" + f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" + f"Call-ID: test-call@example.com\r\n" + f"CSeq: 2 BYE\r\n" + f"\r\n".encode() + ) + tx.response_received(ok_response) + assert tx.branch not in transactions + + """hang_up removes the dialog from sip.dialogs.""" from voip.sip.messages import Dialog diff --git a/voip/rtp.py b/voip/rtp.py index 357fb80..b9d4397 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -188,17 +188,23 @@ async def on_audio_sent(self) -> None: return from voip.sip.messages import Request # noqa: PLC0415 + from voip.sip.transactions import ByeTransaction # noqa: PLC0415 from voip.sip.types import SIPMethod # noqa: PLC0415 request_uri = str(remote_contact).strip("<>").split(";")[0] - bye_branch = f"z9hG4bK-{uuid.uuid4()}" + tx = ByeTransaction( + sip=self.sip, + method=SIPMethod.BYE, + cseq=self.dialog.outbound_cseq, + dialog=self.dialog, + ) bye_request = Request( method=SIPMethod.BYE, uri=request_uri, headers={ "Via": ( f"SIP/2.0/{self.sip.aor.transport}" - f" {self.sip.rtp.public_address};rport;alias;branch={bye_branch}" + f" {self.sip.local_address};rport;branch={tx.branch}" ), "Max-Forwards": "70", "From": self.dialog.local_party, @@ -208,6 +214,7 @@ async def on_audio_sent(self) -> None: "Content-Length": "0", }, ) + self.sip.transactions[tx.branch] = tx self.sip.send(bye_request) self.dialog.outbound_cseq += 1 self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag), None) diff --git a/voip/sip/__init__.py b/voip/sip/__init__.py index 10d5f49..2a76e5b 100644 --- a/voip/sip/__init__.py +++ b/voip/sip/__init__.py @@ -6,7 +6,7 @@ from .messages import Message, Request, Response from .protocol import SessionInitiationProtocol -from .transactions import InviteTransaction, RegistrationTransaction +from .transactions import ByeTransaction, InviteTransaction, RegistrationTransaction from .types import CallerID, SIPMethod, SIPStatus, SipUri __all__ = [ @@ -14,6 +14,7 @@ "Request", "Response", "SessionInitiationProtocol", + "ByeTransaction", "InviteTransaction", "RegistrationTransaction", "CallerID", diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 9d94265..097edb3 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -41,6 +41,7 @@ logger = logging.getLogger("voip.sip") __all__ = [ + "ByeTransaction", "InviteTransaction", "RegistrationTransaction", ] @@ -803,3 +804,36 @@ async def _accept_call(self, response: Response) -> None: ) ) self.sip.transactions.pop(self.branch, None) + + +@dataclasses.dataclass(kw_only=True, slots=True) +class ByeTransaction(Transaction): + """BYE client transaction [RFC 3261 §17.1.2]. + + Sends a BYE request to terminate an established dialog and waits for the + 200 OK acknowledgment from the remote party. Unlike INVITE, BYE responses + do **not** require an ACK — the 200 OK itself ends the transaction. + + This class is created by [`Session.hang_up`][voip.rtp.Session.hang_up] + and registered in the SIP session's transaction table so that the + 200 OK response is routed back here and the transaction is cleaned up. + + [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 + """ + + cseq: int = 1 + + def response_received(self, response: Response) -> None: + """Handle the BYE response (typically 200 OK) [RFC 3261 §15.1.1]. + + Removes this transaction from the SIP session once any final (2xx–6xx) + response is received. Provisional 1xx responses are silently ignored. + + Args: + response: The parsed SIP response to our BYE request. + """ + if response.status_code >= 200: + self.sip.transactions.pop(self.branch, None) + logger.debug( + "BYE acknowledged: %s %s", response.status_code, response.phrase + ) From c834a4e8db87a270f0e96444acdd45afada6a8ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:34:50 +0000 Subject: [PATCH 06/45] =?UTF-8?q?Await=20BYE=20200=20OK=20in=20hang=5Fup()?= =?UTF-8?q?=20with=20configurable=20timeout=20(RFC=203261=20=C2=A715)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/575d4606-5bd2-45ab-89ad-961180abc7b8 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- tests/sip/test_transactions.py | 2 + tests/test_rtp.py | 102 ++++++++++++++++++++++++++++++--- voip/rtp.py | 23 +++++++- voip/sip/transactions.py | 12 ++++ 4 files changed, 129 insertions(+), 10 deletions(-) diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py index 299e4d1..bd27649 100644 --- a/tests/sip/test_transactions.py +++ b/tests/sip/test_transactions.py @@ -1071,6 +1071,7 @@ def test_response_received__removes_transaction_on_200(self): ) tx.response_received(response) assert tx.branch not in sip.transactions + assert tx.acknowledged.is_set() def test_response_received__ignores_provisional_response(self): """response_received leaves the transaction in place for 1xx responses.""" @@ -1089,6 +1090,7 @@ def test_response_received__ignores_provisional_response(self): ) tx.response_received(response) assert tx.branch in sip.transactions + assert not tx.acknowledged.is_set() class TestRegistrationError: diff --git a/tests/test_rtp.py b/tests/test_rtp.py index 880beef..091c663 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -565,7 +565,11 @@ async def test_hang_up__sends_bye(self): mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} mock_sip.transactions = {} call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) - await call.hang_up() + hang_task = asyncio.create_task(call.hang_up()) + await asyncio.sleep(0) + (tx,) = mock_sip.transactions.values() + tx.acknowledged.set() + await hang_task mock_sip.send.assert_called_once() sent = mock_sip.send.call_args[0][0] assert b"BYE" in bytes(sent) @@ -593,16 +597,17 @@ async def test_hang_up__registers_bye_transaction(self): transactions: dict = {} mock_sip.transactions = transactions call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) - await call.hang_up() - assert len(transactions) == 1 + hang_task = asyncio.create_task(call.hang_up()) + await asyncio.sleep(0) (tx,) = transactions.values() assert isinstance(tx, ByeTransaction) assert tx.cseq == 2 + tx.acknowledged.set() + await hang_task async def test_hang_up__bye_transaction_cleaned_up_on_200(self): """ByeTransaction removes itself from sip.transactions when 200 OK arrives.""" from voip.sip.messages import Dialog, Message - from voip.sip.transactions import ByeTransaction mock_sip = MagicMock() mock_sip.aor.transport = "TLS" @@ -620,7 +625,8 @@ async def test_hang_up__bye_transaction_cleaned_up_on_200(self): transactions: dict = {} mock_sip.transactions = transactions call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) - await call.hang_up() + hang_task = asyncio.create_task(call.hang_up()) + await asyncio.sleep(0) (tx,) = transactions.values() ok_response = Message.parse( f"SIP/2.0 200 OK\r\n" @@ -632,9 +638,67 @@ async def test_hang_up__bye_transaction_cleaned_up_on_200(self): f"\r\n".encode() ) tx.response_received(ok_response) + await hang_task assert tx.branch not in transactions + async def test_hang_up__waits_for_bye_acknowledgment(self): + """hang_up blocks until the ByeTransaction is acknowledged.""" + from voip.sip.messages import Dialog + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} + dialog = Dialog( + call_id="test-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + outbound_cseq=2, + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + mock_sip.transactions = {} + call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + hang_task = asyncio.create_task(call.hang_up()) + await asyncio.sleep(0) + assert not hang_task.done(), "hang_up() should still be waiting for BYE ack" + (tx,) = mock_sip.transactions.values() + tx.acknowledged.set() + await hang_task + assert hang_task.done() + + async def test_hang_up__continues_after_bye_timeout(self): + """hang_up logs a warning and continues when BYE is not acknowledged in time.""" + from voip.sip.messages import Dialog + + class ShortTimeoutCall(Session): + BYE_ACK_TIMEOUT = 0.001 # 1 ms — expire immediately in tests + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "192.0.2.1:5061" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_rtp.calls = {} + dialog = Dialog( + call_id="test-call@example.com", + local_party="sip:alice@example.com;tag=our-tag", + remote_party="sip:bob@biloxi.com;tag=callee-tag", + remote_contact="sip:bob@192.0.2.2", + outbound_cseq=2, + ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + mock_sip.transactions = {} + call = ShortTimeoutCall( + sip=mock_sip, + rtp=mock_rtp, + media=make_media(), + caller=CallerID(""), + dialog=dialog, + ) + await call.hang_up() # must not raise despite no 200 OK + + async def test_hang_up__removes_dialog(self): """hang_up removes the dialog from sip.dialogs.""" from voip.sip.messages import Dialog @@ -650,8 +714,13 @@ async def test_hang_up__bye_transaction_cleaned_up_on_200(self): remote_contact="sip:bob@192.0.2.2", ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + mock_sip.transactions = {} call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) - await call.hang_up() + hang_task = asyncio.create_task(call.hang_up()) + await asyncio.sleep(0) + (tx,) = mock_sip.transactions.values() + tx.acknowledged.set() + await hang_task assert (dialog.remote_tag, dialog.local_tag) not in mock_sip.dialogs async def test_hang_up__deregisters_rtp_handler(self): @@ -669,10 +738,15 @@ async def test_hang_up__deregisters_rtp_handler(self): remote_contact="sip:bob@192.0.2.2", ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + mock_sip.transactions = {} call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) remote_addr = ("192.0.2.2", 5004) mock_rtp.calls = {remote_addr: call} - await call.hang_up() + hang_task = asyncio.create_task(call.hang_up()) + await asyncio.sleep(0) + (tx,) = mock_sip.transactions.values() + tx.acknowledged.set() + await hang_task mock_rtp.unregister_call.assert_called_once_with(remote_addr) async def test_hang_up__deregisters_wildcard_rtp_handler(self): @@ -690,9 +764,14 @@ async def test_hang_up__deregisters_wildcard_rtp_handler(self): remote_contact="sip:bob@192.0.2.2", ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + mock_sip.transactions = {} call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) mock_rtp.calls = {None: call} # wildcard registration - await call.hang_up() + hang_task = asyncio.create_task(call.hang_up()) + await asyncio.sleep(0) + (tx,) = mock_sip.transactions.values() + tx.acknowledged.set() + await hang_task mock_rtp.unregister_call.assert_called_once_with(None) async def test_hang_up__skips_unregister_when_handler_not_in_rtp(self): @@ -711,8 +790,13 @@ async def test_hang_up__skips_unregister_when_handler_not_in_rtp(self): remote_contact="sip:bob@192.0.2.2", ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + mock_sip.transactions = {} call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) - await call.hang_up() + hang_task = asyncio.create_task(call.hang_up()) + await asyncio.sleep(0) + (tx,) = mock_sip.transactions.values() + tx.acknowledged.set() + await hang_task mock_rtp.unregister_call.assert_not_called() async def test_hang_up__missing_local_party_is_noop(self): diff --git a/voip/rtp.py b/voip/rtp.py index b9d4397..4c3dd32 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -120,6 +120,15 @@ class Session: srtp: SRTPSession | None = None dialog: Dialog | None = None + BYE_ACK_TIMEOUT: typing.ClassVar[float] = 32.0 + """Seconds to wait for a 200 OK acknowledgment after sending BYE. + + Defaults to 64×T1 = 32 s (the standard non-INVITE transaction timeout + from [RFC 3261 §17.1.2]). Override in subclasses to change the timeout. + + [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 + """ + def packet_received(self, packet: RTPPacket, addr: NetworkAddress) -> None: """Handle a parsed RTP packet. Override in subclasses to process media. @@ -147,7 +156,10 @@ async def hang_up(self) -> None: Constructs and sends a BYE request for the active dialog, removes the dialog from the SIP session's registry, and deregisters the RTP handler - so that no further media packets are dispatched. + so that no further media packets are dispatched. Then **awaits** the + 200 OK acknowledgment from the remote party before returning (standard + non-INVITE transaction timeout of 32 s applies; a warning is logged if + the acknowledgment is not received in time). The method is a no-op when no dialog is associated with this call (e.g. before the call is fully established). @@ -227,6 +239,15 @@ async def on_audio_sent(self) -> None: ) if remote_addr is not _not_found: self.rtp.unregister_call(remote_addr) + # Wait for the remote party to acknowledge the BYE (RFC 3261 §15.1.1). + # Use the standard 64×T1 = 32 s non-INVITE transaction timeout. + try: + await asyncio.wait_for(tx.acknowledged.wait(), timeout=self.BYE_ACK_TIMEOUT) + except TimeoutError: + logger.warning( + "BYE for dialog %s was not acknowledged within 32 s", + self.dialog.call_id, + ) @classmethod def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 097edb3..f23a784 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -817,23 +817,35 @@ class ByeTransaction(Transaction): This class is created by [`Session.hang_up`][voip.rtp.Session.hang_up] and registered in the SIP session's transaction table so that the 200 OK response is routed back here and the transaction is cleaned up. + [`Session.hang_up`][voip.rtp.Session.hang_up] awaits + [`acknowledged`][voip.sip.transactions.ByeTransaction.acknowledged] to + ensure the remote party has received the BYE before returning. [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 """ cseq: int = 1 + acknowledged: asyncio.Event = dataclasses.field( + default_factory=asyncio.Event, + compare=False, + hash=False, + repr=False, + ) def response_received(self, response: Response) -> None: """Handle the BYE response (typically 200 OK) [RFC 3261 §15.1.1]. Removes this transaction from the SIP session once any final (2xx–6xx) response is received. Provisional 1xx responses are silently ignored. + Sets [`acknowledged`][voip.sip.transactions.ByeTransaction.acknowledged] + so that [`Session.hang_up`][voip.rtp.Session.hang_up] can unblock. Args: response: The parsed SIP response to our BYE request. """ if response.status_code >= 200: self.sip.transactions.pop(self.branch, None) + self.acknowledged.set() logger.debug( "BYE acknowledged: %s %s", response.status_code, response.phrase ) From 69807ed9f143d45e0f4cb867e4413b4fb4e20262 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:34:08 +0000 Subject: [PATCH 07/45] Refactor: transactions awaitable, Dialog owns call lifecycle, dialog_class replaces transaction_class Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/07b67480-7d2f-4917-8834-5b7f6f40dae9 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- docs/cookbook.md | 105 ++++++++++++++-- docs/sessions.md | 12 ++ tests/sip/conftest.py | 4 +- tests/sip/test_messages.py | 149 ++++++++++++++++++++++ tests/sip/test_protocol.py | 28 ++--- tests/sip/test_transactions.py | 91 ++++++++++++-- tests/test_rtp.py | 117 +++++++++--------- voip/__main__.py | 45 +++---- voip/ai.py | 5 +- voip/rtp.py | 109 ++--------------- voip/sip/messages.py | 218 ++++++++++++++++++++++++++++++++- voip/sip/protocol.py | 65 ++++++---- voip/sip/transactions.py | 131 ++++++++++++-------- 13 files changed, 782 insertions(+), 297 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index 1d3327d..7bf0983 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1,5 +1,3 @@ -# Cookbook - ## Call Transcription Subclass \[`TranscribeCall`\][voip.ai.TranscribeCall] and override @@ -11,6 +9,7 @@ import asyncio import ssl from voip.ai import TranscribeCall +from voip.sip.messages import Dialog from voip.sip.protocol import SIP @@ -19,9 +18,14 @@ class MyCall(TranscribeCall): print(f"[{self.caller}] {text}") +class MyDialog(Dialog): + def call_received(self) -> None: + self.ringing() + self.accept(call_class=MyCall) + + class MySession(SIP): - def call_received(self, request) -> None: - asyncio.create_task(self.answer(request=request, call_class=MyCall)) + dialog_class = MyDialog async def main(): @@ -74,6 +78,7 @@ import ssl from pocket_tts import TTSModel from voip.ai import AgentCall +from voip.sip.messages import Dialog from voip.sip.protocol import SIP shared_tts = TTSModel.load_model() @@ -86,9 +91,14 @@ class MyCall(AgentCall): voice = "azelma" +class MyDialog(Dialog): + def call_received(self) -> None: + self.ringing() + self.accept(call_class=MyCall) + + class MySession(SIP): - def call_received(self, request) -> None: - asyncio.create_task(self.answer(request=request, call_class=MyCall)) + dialog_class = MyDialog async def main(): @@ -203,8 +213,13 @@ session = SIP( Every \[`Session`\][voip.rtp.Session] subclass exposes a \[`hang_up`\][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE -request (RFC 3261 §15) to terminate the active dialog and deregisters the RTP -handler. You can call it programmatically from within any call class: +request (RFC 3261 §15) by delegating to +\[`Dialog.bye`\][voip.sip.messages.Dialog.bye]. It deregisters the RTP +handler and awaits the 200 OK acknowledgment before returning. + +Override \[`Dialog.call_received`\][voip.sip.messages.Dialog.call_received] +to hook into the call lifecycle, and call `await self.hang_up()` from within +the call class when you want to terminate: ```python import asyncio @@ -213,6 +228,7 @@ import ssl import numpy as np from voip.audio import AudioCall +from voip.sip.messages import Dialog from voip.sip.protocol import SIP @@ -221,12 +237,19 @@ class OneUtteranceCall(AudioCall): async def voice_received(self, audio: np.ndarray) -> None: await self.hang_up() - self.sip.close() # close the SIP transport after hanging up + # dialog.sip.close() to also shut down the SIP transport: + if self.dialog and self.dialog.sip: + self.dialog.sip.close() + + +class MyDialog(Dialog): + def call_received(self) -> None: + self.ringing() + self.accept(call_class=OneUtteranceCall) class MySession(SIP): - def call_received(self, request) -> None: - asyncio.create_task(self.answer(request=request, call_class=OneUtteranceCall)) + dialog_class = MyDialog async def main(): @@ -250,5 +273,61 @@ asyncio.run(main()) \[`hang_up`\][voip.rtp.Session.hang_up] sends the BYE and cleans up the dialog and RTP handler — it does **not** close the SIP transport so that the same \[`SIP`\][voip.sip.protocol.SessionInitiationProtocol] instance can continue -handling other calls. Call `sip.close()` when you also want to tear down the -transport. +handling other calls. Access `self.dialog.sip.close()` when you also want to +tear down the transport. + +## Making Outbound Calls + +Create a \[`Dialog`\][voip.sip.messages.Dialog] subclass, set it as +`dialog_class` on your SIP session, and call +\[`dialog.dial`\][voip.sip.messages.Dialog.dial] from +\[`on_registered`\][voip.sip.protocol.SessionInitiationProtocol.on_registered]: + +```python +import asyncio +import ssl + +from voip.audio import AudioCall +from voip.sip.messages import Dialog +from voip.sip.protocol import SIP + + +class MyCall(AudioCall): + pass + + +class OutboundDialog(Dialog): + def hangup_received(self) -> None: + """Remote party hung up — close the SIP transport.""" + if self.sip: + self.sip.close() + + +class MySession(SIP): + dialog_class = OutboundDialog + + def on_registered(self) -> None: + dialog = OutboundDialog(sip=self) + asyncio.create_task( + dialog.dial("sip:+15551234567@carrier.com", call_class=MyCall) + ) + + +async def main(): + loop = asyncio.get_running_loop() + await loop.create_connection( + lambda: MySession( + aor="sips:alice@carrier.com", + username="alice", + password="secret", + ), + host="sip.carrier.com", + port=5061, + ssl=ssl.create_default_context(), + ) + await asyncio.Future() + + +asyncio.run(main()) +``` + diff --git a/docs/sessions.md b/docs/sessions.md index 5fe3769..d9779f1 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -1,6 +1,18 @@ # Multimedia Sessions / Call Leg Handlers [Session][voip.rtp.Session] is the base class for all call leg handlers. +Each call leg is associated with a [Dialog][voip.sip.messages.Dialog] that +carries the SIP dialog state and provides the call lifecycle hooks. + +## Dialog + +The [`Dialog`][voip.sip.messages.Dialog] class manages the SIP dialog state +and is the primary extension point for application logic. Override +[`call_received`][voip.sip.messages.Dialog.call_received] to accept or reject +inbound calls, and [`hangup_received`][voip.sip.messages.Dialog.hangup_received] +to react when the remote party hangs up. + +::: voip.sip.messages.Dialog ## Base Session diff --git a/tests/sip/conftest.py b/tests/sip/conftest.py index 0be1d30..26c0f56 100644 --- a/tests/sip/conftest.py +++ b/tests/sip/conftest.py @@ -6,8 +6,8 @@ import pytest from voip.rtp import RealtimeTransportProtocol, Session from voip.sdp.types import MediaDescription, RTPPayloadFormat +from voip.sip.messages import Dialog from voip.sip.protocol import SessionInitiationProtocol -from voip.sip.transactions import InviteTransaction from voip.sip.types import SipUri from voip.types import NetworkAddress @@ -79,7 +79,7 @@ async def sip( session = SessionInitiationProtocol( aor=SipUri.parse("sips:alice:secret@example.com:5061"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(fake_transport) if session.keepalive_task is not None: diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index 8054081..2973280 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -136,6 +136,11 @@ def test_parse__raises_value_error_on_invalid_first_line(self): with pytest.raises(ValueError, match="Invalid header"): messages.Message.parse(b"TOOSHORT\r\n\r\n") + def test_parse__raises_value_error_on_malformed_request_line(self): + """Raise ValueError when the request first line has too few parts.""" + with pytest.raises(ValueError, match="Invalid SIP message first line"): + messages.Message.parse(b"INVITE sip:bob\r\nContent-Length: 0\r\n\r\n") + def test___str____returns_decoded_bytes(self): """Return the string representation of a request as decoded bytes.""" request = messages.Request( @@ -359,3 +364,147 @@ def test_from_request__extracts_call_id_and_tags(self): assert dialog.call_id == "call-99@atlanta.com" assert dialog.local_tag == "from-tag-99" assert dialog.remote_tag is not None + + def test_ringing__delegates_to_invite_tx(self): + """ringing() calls ringing() on the invite_tx when it is set.""" + from unittest.mock import MagicMock + + mock_tx = MagicMock() + dialog = messages.Dialog(invite_tx=mock_tx) + dialog.ringing() + mock_tx.ringing.assert_called_once() + + def test_ringing__noop_when_no_invite_tx(self): + """ringing() is a no-op when invite_tx is None.""" + dialog = messages.Dialog() + dialog.ringing() # must not raise + + def test_accept__delegates_to_invite_tx(self): + """accept() calls answer() on the invite_tx when it is set.""" + from unittest.mock import MagicMock + + class FakeCall: + pass + + mock_tx = MagicMock() + dialog = messages.Dialog(invite_tx=mock_tx) + dialog.accept(call_class=FakeCall) + mock_tx.answer.assert_called_once_with(call_class=FakeCall) + + def test_accept__noop_when_no_invite_tx(self): + """accept() is a no-op when invite_tx is None.""" + dialog = messages.Dialog() + dialog.accept(call_class=object) # must not raise + + def test_reject__delegates_to_invite_tx(self): + """reject() calls reject() on the invite_tx when it is set.""" + from unittest.mock import MagicMock + from voip.sip.types import SIPStatus + + mock_tx = MagicMock() + dialog = messages.Dialog(invite_tx=mock_tx) + dialog.reject(SIPStatus.NOT_FOUND) + mock_tx.reject.assert_called_once_with(SIPStatus.NOT_FOUND) + + def test_reject__noop_when_no_invite_tx(self): + """reject() is a no-op when invite_tx is None.""" + dialog = messages.Dialog() + dialog.reject() # must not raise + + def test_call_received__rejects_by_default(self): + """call_received() rejects the call with 486 Busy Here by default.""" + from unittest.mock import MagicMock + + mock_tx = MagicMock() + dialog = messages.Dialog(invite_tx=mock_tx) + dialog.call_received() + mock_tx.reject.assert_called_once() + + def test_hangup_received__is_noop(self): + """hangup_received() base implementation does nothing.""" + dialog = messages.Dialog() + dialog.hangup_received() # must not raise + + async def test_bye__noop_when_sip_is_none(self): + """bye() is a no-op when sip is not set.""" + dialog = messages.Dialog() + await dialog.bye() # must not raise + + async def test_bye__sends_bye_request(self): + """bye() sends a BYE request via dialog.sip.""" + from unittest.mock import MagicMock + + mock_sip = MagicMock() + mock_sip.aor.transport = "TLS" + mock_sip.local_address = "127.0.0.1:5061" + mock_sip.transactions = {} + mock_sip.dialogs = {} + dialog = messages.Dialog( + call_id="test@example.com", + local_party="sip:alice@example.com;tag=a", + remote_party="sip:bob@biloxi.com;tag=b", + remote_contact="sip:bob@192.0.2.2", + outbound_cseq=1, + sip=mock_sip, + ) + import asyncio + + bye_task = asyncio.create_task(dialog.bye()) + await asyncio.sleep(0) + (tx,) = mock_sip.transactions.values() + tx.done.set() + await bye_task + mock_sip.send.assert_called_once() + + async def test_bye__noop_when_local_party_missing(self): + """bye() is a no-op when local_party is not set.""" + from unittest.mock import MagicMock + + mock_sip = MagicMock() + dialog = messages.Dialog( + remote_contact="sip:bob@192.0.2.2", + sip=mock_sip, + ) + await dialog.bye() + mock_sip.send.assert_not_called() + + async def test_bye__noop_when_remote_contact_missing(self): + """bye() is a no-op when remote_contact is not set.""" + from unittest.mock import MagicMock + + mock_sip = MagicMock() + dialog = messages.Dialog( + local_party="sip:alice@example.com;tag=a", + remote_party="sip:bob@biloxi.com;tag=b", + sip=mock_sip, + ) + await dialog.bye() + mock_sip.send.assert_not_called() + + async def test_dial__creates_invite_transaction_and_sends(self): + """dial() creates an InviteTransaction and sends an INVITE.""" + import ipaddress + + from voip.rtp import RealtimeTransportProtocol + from voip.sip.protocol import SessionInitiationProtocol + from voip.sip.types import SipUri + from voip.types import NetworkAddress + + from tests.sip.conftest import CallFixture, FakeTransport + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = SessionInitiationProtocol( + aor=SipUri.parse("sips:alice:secret@example.com"), + rtp=rtp, + dialog_class=messages.Dialog, + ) + sip.transport = transport + sip.local_address = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) + sip.is_secure = True + + dialog = messages.Dialog(sip=sip) + await dialog.dial("sip:bob@biloxi.com", call_class=CallFixture) + assert any(b"INVITE" in data for data in transport.sent) + assert dialog.uac is sip.aor diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index 6d2fc12..43ae873 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -4,7 +4,7 @@ import datetime import ipaddress -from voip.sip.messages import Message, Response +from voip.sip.messages import Dialog, Message, Response from voip.sip.protocol import PING, PONG, SessionInitiationProtocol from voip.sip.transactions import InviteTransaction from voip.sip.types import SIPMethod, SipUri @@ -19,7 +19,7 @@ def test_connection_made__stores_transport(self, fake_transport, rtp): session = SessionInitiationProtocol( aor=SipUri.parse("sips:alice:secret@example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(fake_transport) assert session.transport is fake_transport @@ -29,7 +29,7 @@ def test_connection_made__sets_local_address(self, fake_transport, rtp): session = SessionInitiationProtocol( aor=SipUri.parse("sips:alice:secret@example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(fake_transport) assert str(session.local_address.host) == "127.0.0.1" @@ -41,7 +41,7 @@ def test_connection_made__sets_is_secure_for_tls(self, rtp): session = SessionInitiationProtocol( aor=SipUri.parse("sips:alice:secret@example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(transport) assert session.is_secure is True @@ -52,7 +52,7 @@ def test_connection_made__is_not_secure_without_ssl(self, rtp): session = SessionInitiationProtocol( aor=SipUri.parse("sip:alice:secret@example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(transport) assert session.is_secure is False @@ -62,7 +62,7 @@ async def test_connection_made__sends_register(self, fake_transport, rtp): session = SessionInitiationProtocol( aor=SipUri.parse("sips:alice:secret@example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(fake_transport) if session.keepalive_task: @@ -74,7 +74,7 @@ async def test_connection_made__creates_keepalive_task(self, fake_transport, rtp session = SessionInitiationProtocol( aor=SipUri.parse("sips:alice:secret@example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(fake_transport) assert session.keepalive_task is not None @@ -152,10 +152,8 @@ def test_allowed_methods__includes_options(self, sip): """Always include OPTIONS in allowed methods.""" assert "OPTIONS" in sip.allowed_methods - def test_allowed_methods__includes_invite_when_transaction_class_has_handler( - self, sip - ): - """Include INVITE when transaction_class defines invite_received.""" + def test_allowed_methods__includes_invite(self, sip): + """Always include INVITE since InviteTransaction defines invite_received.""" assert SIPMethod.INVITE in sip.allowed_methods def test_allow_header__is_comma_separated_string(self, sip): @@ -273,7 +271,7 @@ def test_contact__sip_aor_with_tls_produces_transport_param(self, rtp): session = SessionInitiationProtocol( aor=SipUri.parse("sip:alice:secret@example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(transport) if session.keepalive_task: @@ -286,7 +284,7 @@ def test_contact__sip_aor_without_tls_has_no_transport_param(self, rtp): session = SessionInitiationProtocol( aor=SipUri.parse("sip:alice:secret@example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(transport) assert "transport=tls" not in session.contact @@ -297,7 +295,7 @@ def test_contact__aor_without_user_omits_user(self, rtp): session = SessionInitiationProtocol( aor=SipUri(scheme="sips", host="example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.connection_made(transport) if session.keepalive_task: @@ -349,7 +347,7 @@ def _make_session(self, rtp, fake_transport=None): session = SessionInitiationProtocol( aor=SipUri.parse("sips:alice@example.com"), rtp=rtp, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) transport = fake_transport or FakeTransport() session.transport = transport diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py index bd27649..a869fb1 100644 --- a/tests/sip/test_transactions.py +++ b/tests/sip/test_transactions.py @@ -1,5 +1,8 @@ """Tests for the SIP transaction layer.""" +import asyncio +import dataclasses + import pytest from voip.rtp import RealtimeTransportProtocol from voip.sip.exceptions import RegistrationError @@ -26,6 +29,7 @@ def create_sip_session(fake_transport=None, rtp=None): """Create a minimal SessionInitiationProtocol without async event loop.""" + from voip.sip.messages import Dialog from voip.sip.protocol import SessionInitiationProtocol transport = fake_transport or FakeTransport() @@ -33,7 +37,7 @@ def create_sip_session(fake_transport=None, rtp=None): session = SessionInitiationProtocol( aor=SipUri.parse("sips:alice:secret@example.com"), rtp=mux, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) # Set up local_address without triggering async registration import ipaddress @@ -99,6 +103,17 @@ def test_response_received__is_noop(self): ) assert tx.response_received(Response(status_code=200, phrase="OK")) is None + async def test_await__suspends_until_done_is_set(self): + """Awaiting a transaction suspends until done.set() is called.""" + sip = create_sip_session() + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + task = asyncio.create_task(asyncio.wait_for(asyncio.shield(tx), timeout=1.0)) + await asyncio.sleep(0) + assert not task.done() + tx.done.set() + await task + assert task.done() + def test_send_response__calls_sip_send(self): """Send a response through the SIP layer.""" transport = FakeTransport() @@ -429,7 +444,7 @@ def on_registered(self) -> None: session = TrackingSession( aor=SipUri.parse("sips:alice:secret@example.com"), rtp=mux, - transaction_class=InviteTransaction, + dialog_class=Dialog, ) session.transport = transport session.local_address = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) @@ -450,12 +465,17 @@ def on_registered(self) -> None: class TestInviteTransaction: - def test_invite_received__is_noop(self): - """invite_received base implementation does nothing.""" - sip = create_sip_session() + def test_invite_received__delegates_to_dialog(self): + """invite_received sets dialog.invite_tx, dialog.sip and calls dialog.call_received().""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) request = Message.parse(INVITE_BYTES) tx = InviteTransaction.from_request(request=request, sip=sip) - assert tx.invite_received(request) is None + # Base Dialog.call_received() rejects with 486. + tx.invite_received(request) + assert tx.dialog.invite_tx is tx + assert tx.dialog.sip is sip + assert any(b"486" in data for data in transport.sent) def test_ack_received__removes_transaction(self): """ack_received removes the transaction from sip.transactions.""" @@ -475,9 +495,10 @@ def test_ack_received__removes_transaction(self): ) tx.ack_received(ack) assert tx.branch not in sip.transactions + assert tx.done.is_set() def test_bye_received__removes_dialog_and_sends_200(self): - """bye_received removes the dialog and sends 200 OK.""" + """bye_received removes the dialog, sends 200 OK, and calls hangup_received.""" transport = FakeTransport() sip = create_sip_session(fake_transport=transport) request = Message.parse(INVITE_BYTES) @@ -497,6 +518,42 @@ def test_bye_received__removes_dialog_and_sends_200(self): assert any(b"200" in data for data in transport.sent) assert (tx.dialog.remote_tag, tx.dialog.local_tag) not in sip.dialogs + def test_bye_received__calls_hangup_received(self): + """bye_received calls dialog.hangup_received() after sending 200 OK.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + request = Message.parse(INVITE_BYTES) + + hangup_calls: list[bool] = [] + + @dataclasses.dataclass(kw_only=True) + class TrackingDialog(Dialog): + def hangup_received(self) -> None: + hangup_calls.append(True) + + tx = InviteTransaction.from_request(request=request, sip=sip) + # Replace dialog with TrackingDialog instance that has the same identity fields + tracking_dialog = TrackingDialog( + call_id=tx.dialog.call_id, + local_tag=tx.dialog.local_tag, + remote_tag=tx.dialog.remote_tag, + remote_contact=tx.dialog.remote_contact, + ) + tx.dialog = tracking_dialog + sip.dialogs[(tracking_dialog.remote_tag, tracking_dialog.local_tag)] = tracking_dialog + + bye = Message.parse( + b"BYE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbye002\r\n" + b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: test-call-id@biloxi.com\r\n" + b"CSeq: 2 BYE\r\n" + b"\r\n" + ) + tx.bye_received(bye) + assert hangup_calls == [True] + def test_cancel_received__removes_transaction_and_sends_200(self): """cancel_received removes the transaction, the dialog, and sends 200 OK.""" transport = FakeTransport() @@ -685,6 +742,22 @@ async def test_make_call__sdp_offer_contains_codec(self): assert b"application/sdp" in sent_data assert b"m=audio" in sent_data + async def test_make_call__with_existing_dialog_reuses_it(self): + """make_call() with a dialog parameter reuses that dialog instance.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + existing_dialog = Dialog() + tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture, dialog=existing_dialog) + assert tx.dialog is existing_dialog + assert existing_dialog.sip is sip + def test_response_received__100_is_noop(self): """1xx provisional responses are silently ignored.""" transport = FakeTransport() @@ -1071,7 +1144,7 @@ def test_response_received__removes_transaction_on_200(self): ) tx.response_received(response) assert tx.branch not in sip.transactions - assert tx.acknowledged.is_set() + assert tx.done.is_set() def test_response_received__ignores_provisional_response(self): """response_received leaves the transaction in place for 1xx responses.""" @@ -1090,7 +1163,7 @@ def test_response_received__ignores_provisional_response(self): ) tx.response_received(response) assert tx.branch in sip.transactions - assert not tx.acknowledged.is_set() + assert not tx.done.is_set() class TestRegistrationError: diff --git a/tests/test_rtp.py b/tests/test_rtp.py index 091c663..cd97bb1 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -20,10 +20,9 @@ def make_media() -> MediaDescription: def make_call(**kwargs) -> Session: - """Create an RTPCall with mock rtp/sip for unit testing.""" + """Create an RTPCall with mock rtp for unit testing.""" defaults: dict = { "rtp": MagicMock(spec=RealtimeTransportProtocol), - "sip": MagicMock(), "media": make_media(), "caller": CallerID(""), } @@ -162,7 +161,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = RecordCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) remote_addr = ("127.0.0.1", 5004) mux.register_call(remote_addr, handler) @@ -189,7 +188,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = RecordCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) mux.register_call(("127.0.0.1", 5004), handler) # 5 bytes is shorter than the 12-byte minimum RTP header — must not raise. @@ -207,7 +206,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() mux.connection_made(MagicMock(spec=asyncio.DatagramTransport)) handler = RecordCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) mux.register_call(None, handler) stun_bytes = b"\x01\x01" + b"\x00" * 18 # first byte = 1 (STUN range [0,3]) @@ -293,10 +292,10 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() specific_addr = ("1.2.3.4", 5004) wildcard_handler = WildcardCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) specific_handler = SpecificCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) mux.register_call(None, wildcard_handler) mux.register_call(specific_addr, specific_handler) @@ -318,7 +317,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = WildcardCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) mux.register_call(None, handler) @@ -338,7 +337,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = RecordCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) remote_addr = ("5.6.7.8", 5004) mux.register_call(remote_addr, handler) @@ -354,7 +353,7 @@ async def test_register_call__logs_info(self, caplog): mux = RealtimeTransportProtocol() handler = Session( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) with caplog.at_level(logging.INFO, logger="voip.rtp"): mux.register_call(("1.2.3.4", 5004), handler) @@ -367,7 +366,7 @@ async def test_unregister_call__logs_info(self, caplog): mux = RealtimeTransportProtocol() handler = Session( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) addr = ("1.2.3.4", 5004) mux.register_call(addr, handler) @@ -386,7 +385,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = CapturingCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, media=make_media(), caller=CallerID("") ) mux.register_call(None, handler) packet = make_rtp_packet() @@ -430,7 +429,6 @@ def packet_received(self, packet: RTPPacket, addr) -> None: session = SRTPSession.generate() handler = SRTPCapture( rtp=mux, - sip=MagicMock(), media=make_media(), srtp=session, caller=CallerID(""), @@ -462,7 +460,6 @@ def packet_received(self, packet: RTPPacket, addr) -> None: session = SRTPSession.generate() handler = SRTPCapture( rtp=mux, - sip=MagicMock(), media=make_media(), srtp=session, caller=CallerID(""), @@ -495,15 +492,13 @@ def test_media__stored_on_instance(self): media = make_media() assert make_call(media=media).media is media - def test_rtp_and_sip_stored_as_fields(self): - """Rtp and sip back-references are stored on the instance.""" + def test_rtp_stored_as_field(self): + """Rtp back-reference is stored on the instance.""" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_sip = MagicMock() call = Session( - rtp=mock_rtp, sip=mock_sip, media=make_media(), caller=CallerID("") + rtp=mock_rtp, media=make_media(), caller=CallerID("") ) assert call.rtp is mock_rtp - assert call.sip is mock_sip def test_packet_received__noop_by_default(self): """packet_received is a no-op in the base class.""" @@ -561,14 +556,15 @@ async def test_hang_up__sends_bye(self): remote_party="sip:bob@biloxi.com;tag=callee-tag", remote_contact="sip:bob@192.0.2.2", outbound_cseq=2, + sip=mock_sip, ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} mock_sip.transactions = {} - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + call = make_call(rtp=mock_rtp, dialog=dialog) hang_task = asyncio.create_task(call.hang_up()) await asyncio.sleep(0) (tx,) = mock_sip.transactions.values() - tx.acknowledged.set() + tx.done.set() await hang_task mock_sip.send.assert_called_once() sent = mock_sip.send.call_args[0][0] @@ -586,23 +582,24 @@ async def test_hang_up__registers_bye_transaction(self): mock_sip.local_address = "192.0.2.1:5061" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_rtp.calls = {} + transactions: dict = {} + mock_sip.transactions = transactions dialog = Dialog( call_id="test-call@example.com", local_party="sip:alice@example.com;tag=our-tag", remote_party="sip:bob@biloxi.com;tag=callee-tag", remote_contact="sip:bob@192.0.2.2", outbound_cseq=2, + sip=mock_sip, ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - transactions: dict = {} - mock_sip.transactions = transactions - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + call = make_call(rtp=mock_rtp, dialog=dialog) hang_task = asyncio.create_task(call.hang_up()) await asyncio.sleep(0) (tx,) = transactions.values() assert isinstance(tx, ByeTransaction) assert tx.cseq == 2 - tx.acknowledged.set() + tx.done.set() await hang_task async def test_hang_up__bye_transaction_cleaned_up_on_200(self): @@ -614,17 +611,18 @@ async def test_hang_up__bye_transaction_cleaned_up_on_200(self): mock_sip.local_address = "192.0.2.1:5061" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_rtp.calls = {} + transactions: dict = {} + mock_sip.transactions = transactions dialog = Dialog( call_id="test-call@example.com", local_party="sip:alice@example.com;tag=our-tag", remote_party="sip:bob@biloxi.com;tag=callee-tag", remote_contact="sip:bob@192.0.2.2", outbound_cseq=2, + sip=mock_sip, ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - transactions: dict = {} - mock_sip.transactions = transactions - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + call = make_call(rtp=mock_rtp, dialog=dialog) hang_task = asyncio.create_task(call.hang_up()) await asyncio.sleep(0) (tx,) = transactions.values() @@ -642,7 +640,7 @@ async def test_hang_up__bye_transaction_cleaned_up_on_200(self): assert tx.branch not in transactions async def test_hang_up__waits_for_bye_acknowledgment(self): - """hang_up blocks until the ByeTransaction is acknowledged.""" + """hang_up blocks until the ByeTransaction is done.""" from voip.sip.messages import Dialog mock_sip = MagicMock() @@ -650,21 +648,22 @@ async def test_hang_up__waits_for_bye_acknowledgment(self): mock_sip.local_address = "192.0.2.1:5061" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_rtp.calls = {} + mock_sip.transactions = {} dialog = Dialog( call_id="test-call@example.com", local_party="sip:alice@example.com;tag=our-tag", remote_party="sip:bob@biloxi.com;tag=callee-tag", remote_contact="sip:bob@192.0.2.2", outbound_cseq=2, + sip=mock_sip, ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - mock_sip.transactions = {} - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + call = make_call(rtp=mock_rtp, dialog=dialog) hang_task = asyncio.create_task(call.hang_up()) await asyncio.sleep(0) assert not hang_task.done(), "hang_up() should still be waiting for BYE ack" (tx,) = mock_sip.transactions.values() - tx.acknowledged.set() + tx.done.set() await hang_task assert hang_task.done() @@ -672,7 +671,7 @@ async def test_hang_up__continues_after_bye_timeout(self): """hang_up logs a warning and continues when BYE is not acknowledged in time.""" from voip.sip.messages import Dialog - class ShortTimeoutCall(Session): + class ShortTimeoutDialog(Dialog): BYE_ACK_TIMEOUT = 0.001 # 1 ms — expire immediately in tests mock_sip = MagicMock() @@ -680,22 +679,17 @@ class ShortTimeoutCall(Session): mock_sip.local_address = "192.0.2.1:5061" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_rtp.calls = {} - dialog = Dialog( + mock_sip.transactions = {} + dialog = ShortTimeoutDialog( call_id="test-call@example.com", local_party="sip:alice@example.com;tag=our-tag", remote_party="sip:bob@biloxi.com;tag=callee-tag", remote_contact="sip:bob@192.0.2.2", outbound_cseq=2, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - mock_sip.transactions = {} - call = ShortTimeoutCall( sip=mock_sip, - rtp=mock_rtp, - media=make_media(), - caller=CallerID(""), - dialog=dialog, ) + mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} + call = make_call(rtp=mock_rtp, dialog=dialog) await call.hang_up() # must not raise despite no 200 OK async def test_hang_up__removes_dialog(self): @@ -707,19 +701,20 @@ async def test_hang_up__removes_dialog(self): mock_sip.local_address = "192.0.2.1:5061" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_rtp.calls = {} + mock_sip.transactions = {} dialog = Dialog( call_id="test-call@example.com", local_party="sip:alice@example.com;tag=our-tag", remote_party="sip:bob@biloxi.com;tag=callee-tag", remote_contact="sip:bob@192.0.2.2", + sip=mock_sip, ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - mock_sip.transactions = {} - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + call = make_call(rtp=mock_rtp, dialog=dialog) hang_task = asyncio.create_task(call.hang_up()) await asyncio.sleep(0) (tx,) = mock_sip.transactions.values() - tx.acknowledged.set() + tx.done.set() await hang_task assert (dialog.remote_tag, dialog.local_tag) not in mock_sip.dialogs @@ -731,21 +726,22 @@ async def test_hang_up__deregisters_rtp_handler(self): mock_sip.aor.transport = "TLS" mock_sip.local_address = "192.0.2.1:5061" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_sip.transactions = {} dialog = Dialog( call_id="test-call@example.com", local_party="sip:alice@example.com;tag=our-tag", remote_party="sip:bob@biloxi.com;tag=callee-tag", remote_contact="sip:bob@192.0.2.2", + sip=mock_sip, ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - mock_sip.transactions = {} - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + call = make_call(rtp=mock_rtp, dialog=dialog) remote_addr = ("192.0.2.2", 5004) mock_rtp.calls = {remote_addr: call} hang_task = asyncio.create_task(call.hang_up()) await asyncio.sleep(0) (tx,) = mock_sip.transactions.values() - tx.acknowledged.set() + tx.done.set() await hang_task mock_rtp.unregister_call.assert_called_once_with(remote_addr) @@ -757,20 +753,21 @@ async def test_hang_up__deregisters_wildcard_rtp_handler(self): mock_sip.aor.transport = "TLS" mock_sip.local_address = "192.0.2.1:5061" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_sip.transactions = {} dialog = Dialog( call_id="test-call@example.com", local_party="sip:alice@example.com;tag=our-tag", remote_party="sip:bob@biloxi.com;tag=callee-tag", remote_contact="sip:bob@192.0.2.2", + sip=mock_sip, ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - mock_sip.transactions = {} - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + call = make_call(rtp=mock_rtp, dialog=dialog) mock_rtp.calls = {None: call} # wildcard registration hang_task = asyncio.create_task(call.hang_up()) await asyncio.sleep(0) (tx,) = mock_sip.transactions.values() - tx.acknowledged.set() + tx.done.set() await hang_task mock_rtp.unregister_call.assert_called_once_with(None) @@ -783,19 +780,20 @@ async def test_hang_up__skips_unregister_when_handler_not_in_rtp(self): mock_sip.local_address = "192.0.2.1:5061" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_rtp.calls = {} # call not registered + mock_sip.transactions = {} dialog = Dialog( call_id="test-call@example.com", local_party="sip:alice@example.com;tag=our-tag", remote_party="sip:bob@biloxi.com;tag=callee-tag", remote_contact="sip:bob@192.0.2.2", + sip=mock_sip, ) mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - mock_sip.transactions = {} - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + call = make_call(rtp=mock_rtp, dialog=dialog) hang_task = asyncio.create_task(call.hang_up()) await asyncio.sleep(0) (tx,) = mock_sip.transactions.values() - tx.acknowledged.set() + tx.done.set() await hang_task mock_rtp.unregister_call.assert_not_called() @@ -806,8 +804,12 @@ async def test_hang_up__missing_local_party_is_noop(self): mock_sip = MagicMock() mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_rtp.calls = {} - dialog = Dialog(call_id="test@example.com", remote_contact="sip:bob@192.0.2.2") - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + dialog = Dialog( + call_id="test@example.com", + remote_contact="sip:bob@192.0.2.2", + sip=mock_sip, + ) + call = make_call(rtp=mock_rtp, dialog=dialog) await call.hang_up() mock_sip.send.assert_not_called() @@ -822,7 +824,8 @@ async def test_hang_up__missing_remote_contact_is_noop(self): call_id="test@example.com", local_party="sip:alice@example.com;tag=our-tag", remote_party="sip:bob@biloxi.com;tag=callee-tag", + sip=mock_sip, ) - call = make_call(sip=mock_sip, rtp=mock_rtp, dialog=dialog) + call = make_call(rtp=mock_rtp, dialog=dialog) await call.hang_up() mock_sip.send.assert_not_called() diff --git a/voip/__main__.py b/voip/__main__.py index 48ea0a2..a7666bc 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -12,8 +12,7 @@ from voip.rtp import RealtimeTransportProtocol from voip.sip import messages from voip.sip.protocol import SessionInitiationProtocol -from voip.sip.transactions import InviteTransaction -from voip.sip.types import SIPMethod, SipUri +from voip.sip.types import SipUri from voip.types import NetworkAddress try: @@ -241,29 +240,25 @@ def _make_outbound_factory( """ target = str(target_uri) - class OutboundInviteTransaction(InviteTransaction): - def bye_received(self, request: messages.Request) -> None: - super().bye_received(request) - self.sip.close() + class OutboundDialog(messages.Dialog): + def hangup_received(self) -> None: + if self.sip is not None: + self.sip.close() @dataclasses.dataclass(kw_only=True, slots=True) class OutboundProtocol(ConsoleMessageProtocol): dial_target: str def on_registered(self) -> None: - tx = OutboundInviteTransaction( - sip=self, - method=SIPMethod.INVITE, - cseq=1, - ) + dialog = OutboundDialog(sip=self) asyncio.create_task( - tx.make_call(self.dial_target, call_class=call_class, **call_kwargs) + dialog.dial(self.dial_target, call_class=call_class, **call_kwargs) ) def factory() -> ConsoleMessageProtocol: return OutboundProtocol( verbose=verbose, - transaction_class=OutboundInviteTransaction, + dialog_class=OutboundDialog, aor=aor, rtp=rtp_protocol, dial_target=target, @@ -314,10 +309,10 @@ def echo(ctx, dial: str | None): aor = obj["aor"] target_uri = _parse_dial_target(dial) - class EchoInviteTransaction(InviteTransaction): - def invite_received(self, request: messages.Request) -> None: + class EchoDialog(messages.Dialog): + def call_received(self) -> None: self.ringing() - self.answer(call_class=EchoCall) + self.accept(call_class=EchoCall) async def run(): _, rtp_protocol = await _connect_rtp( @@ -328,7 +323,7 @@ async def run(): await _connect_sip( lambda: ConsoleMessageProtocol( verbose=obj.get("verbose", 0), - transaction_class=EchoInviteTransaction, + dialog_class=EchoDialog, aor=aor, rtp=rtp_protocol, ), @@ -393,10 +388,10 @@ class TranscribingCall(TranscribeCall): def transcription_received(self, text: str) -> None: click.echo(click.style(text, fg="green", bold=True)) - class TranscribeInviteTransaction(InviteTransaction): - def invite_received(self, request: messages.Request) -> None: + class TranscribeDialog(messages.Dialog): + def call_received(self) -> None: self.ringing() - self.answer( + self.accept( call_class=TranscribingCall, stt_model=WhisperModel(stt_model), ) @@ -410,7 +405,7 @@ async def run(): await _connect_sip( lambda: ConsoleMessageProtocol( verbose=obj.get("verbose", 0), - transaction_class=TranscribeInviteTransaction, + dialog_class=TranscribeDialog, aor=aor, rtp=rtp_protocol, ), @@ -524,10 +519,10 @@ async def respond(self) -> None: self.msg_count = len(self._messages) await super().respond() - class AgentInviteTransaction(InviteTransaction): - def invite_received(self, request: messages.Request) -> None: + class AgentDialog(messages.Dialog): + def call_received(self) -> None: self.ringing() - self.answer( + self.accept( call_class=AgentCallWithOutput, stt_model=WhisperModel(stt_model), llm_model=llm_model, @@ -545,7 +540,7 @@ async def run(): await _connect_sip( lambda: ConsoleMessageProtocol( verbose=obj.get("verbose", 0), - transaction_class=AgentInviteTransaction, + dialog_class=AgentDialog, aor=aor, rtp=rtp_protocol, ), diff --git a/voip/ai.py b/voip/ai.py index 597b188..e7e0e0c 100644 --- a/voip/ai.py +++ b/voip/ai.py @@ -186,11 +186,12 @@ async def hang_up(self) -> None: """Send BYE and close the SIP transport. Extends the base [`hang_up`][voip.rtp.Session.hang_up] by also - closing the SIP transport after the BYE is sent, terminating the + closing the SIP transport after the BYE is acknowledged, terminating the single-shot outbound call session. """ await super().hang_up() - self.sip.close() + if self.dialog is not None and self.dialog.sip is not None: + self.dialog.sip.close() @dataclasses.dataclass(kw_only=True, slots=True) diff --git a/voip/rtp.py b/voip/rtp.py index 4c3dd32..aa51e08 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -21,7 +21,6 @@ if TYPE_CHECKING: from voip.sip.messages import Dialog - from voip.sip.protocol import SessionInitiationProtocol from voip.sip.types import CallerID __all__ = ["RTP", "Session", "RTPPacket", "RTPPayloadType", "RealtimeTransportProtocol"] @@ -96,39 +95,30 @@ class Session: stream. Subclass and override `packet_received` to process incoming media, and use `send_packet` to transmit outbound media. - The `rtp` and `sip` back-references allow the handler to send data - back to the caller and to terminate the call via SIP BYE. + The `rtp` back-reference allows sending media; the `dialog` back-reference + carries the SIP dialog state and a reference to the SIP session + (``dialog.sip``) so that the transport can be closed when the call ends. Subclass `voip.audio.AudioCall` for audio calls with codec negotiation, buffering, and decoding. Attributes: rtp: Shared RTP multiplexer socket that delivers packets to this handler. - sip: SIP session that answered this call (used for BYE etc.). caller: Caller identifier as received in the SIP From header. media: Negotiated SDP media description for this call leg. srtp: Optional SRTP session for encrypting and decrypting media. dialog: SIP dialog state for this call leg. Set by the transaction layer after the call is established; used by - [`hang_up`][voip.rtp.Session.hang_up] to send BYE. + [`hang_up`][voip.rtp.Session.hang_up] to send BYE via + [`Dialog.bye`][voip.sip.messages.Dialog.bye]. """ rtp: RealtimeTransportProtocol - sip: SessionInitiationProtocol media: MediaDescription caller: CallerID srtp: SRTPSession | None = None dialog: Dialog | None = None - BYE_ACK_TIMEOUT: typing.ClassVar[float] = 32.0 - """Seconds to wait for a 200 OK acknowledgment after sending BYE. - - Defaults to 64×T1 = 32 s (the standard non-INVITE transaction timeout - from [RFC 3261 §17.1.2]). Override in subclasses to change the timeout. - - [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 - """ - def packet_received(self, packet: RTPPacket, addr: NetworkAddress) -> None: """Handle a parsed RTP packet. Override in subclasses to process media. @@ -154,84 +144,19 @@ def send_packet(self, packet: RTPPacket, addr: NetworkAddress) -> None: async def hang_up(self) -> None: """Terminate the call by sending a SIP BYE request [RFC 3261 §15]. - Constructs and sends a BYE request for the active dialog, removes the - dialog from the SIP session's registry, and deregisters the RTP handler - so that no further media packets are dispatched. Then **awaits** the - 200 OK acknowledgment from the remote party before returning (standard - non-INVITE transaction timeout of 32 s applies; a warning is logged if - the acknowledgment is not received in time). - - The method is a no-op when no dialog is associated with this call (e.g. - before the call is fully established). + Deregisters this call from the RTP multiplexer, then delegates the + BYE signaling to [`Dialog.bye`][voip.sip.messages.Dialog.bye], which + constructs and sends the BYE request, removes the dialog from the + SIP session's registry, and awaits the 200 OK acknowledgment. - Call `sip.close()` afterwards when you also want to shut down the SIP - transport (e.g. after a single-shot outbound call). - - Example: - Override [`on_audio_sent`][voip.audio.AudioCall.on_audio_sent] to - hang up programmatically once all outbound audio has been sent: - - ```python - from voip.audio import AudioCall - - - class SayAndHangUp(AudioCall): - async def on_audio_sent(self) -> None: - await self.hang_up() - ``` + The method is a no-op when no dialog is associated with this call. [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 """ if self.dialog is None: return - if self.dialog.local_party is None or self.dialog.remote_party is None: - logger.warning( - "Cannot hang up dialog %s: local or remote party not set", - self.dialog.call_id, - ) - return - - remote_contact = self.dialog.remote_contact - if remote_contact is None: - logger.warning( - "Cannot hang up dialog %s: remote contact not known", - self.dialog.call_id, - ) - return - - from voip.sip.messages import Request # noqa: PLC0415 - from voip.sip.transactions import ByeTransaction # noqa: PLC0415 - from voip.sip.types import SIPMethod # noqa: PLC0415 - - request_uri = str(remote_contact).strip("<>").split(";")[0] - tx = ByeTransaction( - sip=self.sip, - method=SIPMethod.BYE, - cseq=self.dialog.outbound_cseq, - dialog=self.dialog, - ) - bye_request = Request( - method=SIPMethod.BYE, - uri=request_uri, - headers={ - "Via": ( - f"SIP/2.0/{self.sip.aor.transport}" - f" {self.sip.local_address};rport;branch={tx.branch}" - ), - "Max-Forwards": "70", - "From": self.dialog.local_party, - "To": self.dialog.remote_party, - "Call-ID": self.dialog.call_id, - "CSeq": f"{self.dialog.outbound_cseq} {SIPMethod.BYE}", - "Content-Length": "0", - }, - ) - self.sip.transactions[tx.branch] = tx - self.sip.send(bye_request) - self.dialog.outbound_cseq += 1 - self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag), None) - # Deregister the RTP handler for this call. Use a sentinel so that a - # wildcard handler registered under addr=None can still be removed. + # Deregister the RTP handler for this call so no further media is + # dispatched while the BYE is in flight. _not_found = object() remote_addr = next( (addr for addr, call in self.rtp.calls.items() if call is self), @@ -239,15 +164,7 @@ async def on_audio_sent(self) -> None: ) if remote_addr is not _not_found: self.rtp.unregister_call(remote_addr) - # Wait for the remote party to acknowledge the BYE (RFC 3261 §15.1.1). - # Use the standard 64×T1 = 32 s non-INVITE transaction timeout. - try: - await asyncio.wait_for(tx.acknowledged.wait(), timeout=self.BYE_ACK_TIMEOUT) - except TimeoutError: - logger.warning( - "BYE for dialog %s was not acknowledged within 32 s", - self.dialog.call_id, - ) + await self.dialog.bye() @classmethod def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: diff --git a/voip/sip/messages.py b/voip/sip/messages.py index 74644e0..3f8263c 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -1,9 +1,14 @@ """SIP message types as defined by RFC 3261.""" +from __future__ import annotations + import abc +import asyncio import dataclasses import datetime +import logging import socket +import typing import uuid from urllib3 import HTTPHeaderDict @@ -13,8 +18,14 @@ from ..types import ByteSerializableObject from .types import CallerID, SIPMethod, SIPStatus, SipUri +if typing.TYPE_CHECKING: + from voip.sip.protocol import SessionInitiationProtocol + from voip.sip.transactions import InviteTransaction + __all__ = ["Request", "Response", "Message", "Dialog"] +logger = logging.getLogger("voip.sip") + #: Headers whose values are parsed as `CallerID` objects. CALLER_IDS_HEADERS = frozenset({"From", "To", "Route", "Record-Route", "Contact"}) @@ -182,7 +193,31 @@ class Dialog: Peer-to-peer SIP relationship between two user agents. A dialog is identified by the tuple of (Call-ID, From tag, To tag) and - established by a non-final response to the INVITE, see also: [RFC 3261 §12] + established by a non-final response to the INVITE, see also: [RFC 3261 §12]. + + Subclass `Dialog` to implement inbound call handling. Override + [`call_received`][voip.sip.messages.Dialog.call_received] and call + [`accept`][voip.sip.messages.Dialog.accept] or + [`reject`][voip.sip.messages.Dialog.reject] from within it. Register the + subclass as `dialog_class` on the SIP session: + + ```python + class MyDialog(Dialog): + def call_received(self) -> None: + self.ringing() + self.accept(call_class=MyCall) + + class MySession(SessionInitiationProtocol): + dialog_class = MyDialog + ``` + + For outbound calls, create a `Dialog` with the SIP session set and call + [`dial`][voip.sip.messages.Dialog.dial]: + + ```python + dialog = Dialog(sip=my_sip_session) + await dialog.dial("sip:bob@biloxi.com", call_class=MyCall) + ``` [RFC 3261 §12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 @@ -201,7 +236,21 @@ class Dialog: in-dialog request. Defaults to ``1`` for the UAS side (no prior outbound request) and is set to ``cseq + 1`` on the UAC side after the INVITE is confirmed. + sip: The SIP session that owns this dialog. Set by the transaction + layer when the dialog is confirmed. + invite_tx: The [`InviteTransaction`][voip.sip.transactions.InviteTransaction] + for an inbound INVITE. Set before + [`call_received`][voip.sip.messages.Dialog.call_received] is called + so that [`accept`][voip.sip.messages.Dialog.accept], + [`reject`][voip.sip.messages.Dialog.reject], and + [`ringing`][voip.sip.messages.Dialog.ringing] can delegate to it. + """ + BYE_ACK_TIMEOUT: typing.ClassVar[float] = 32.0 + """Seconds to wait for a 200 OK from the remote party after sending BYE. + + Defaults to 64×T1 = 32 s (RFC 3261 §17.1.2). Override in subclasses to + change the timeout. """ uac: SipUri | None = None @@ -218,6 +267,12 @@ class Dialog: local_party: str | None = dataclasses.field(default=None, compare=False) remote_party: str | None = dataclasses.field(default=None, compare=False) outbound_cseq: int = dataclasses.field(default=1, compare=False) + sip: SessionInitiationProtocol | None = dataclasses.field( + default=None, compare=False, repr=False + ) + invite_tx: InviteTransaction | None = dataclasses.field( + default=None, compare=False, repr=False + ) created: datetime.datetime = dataclasses.field( init=False, default_factory=datetime.datetime.now @@ -245,6 +300,167 @@ def headers(self) -> dict[str, str]: "Call-ID": self.call_id, } + def call_received(self) -> None: + """Handle an incoming INVITE. + + Called by the SIP layer after the dialog is created from the INVITE + request. The base implementation rejects the call with ``486 Busy + Here``. Override in subclasses to answer, ring, or reject the call + using [`accept`][voip.sip.messages.Dialog.accept], + [`ringing`][voip.sip.messages.Dialog.ringing], and + [`reject`][voip.sip.messages.Dialog.reject]. + """ + self.reject() + + def hangup_received(self) -> None: + """Handle an inbound BYE (remote party hanging up). + + Called by the SIP layer after the 200 OK response has been sent for + the BYE. The base implementation is a no-op. Override in subclasses + to perform teardown, e.g. closing the SIP transport for single-shot + outbound sessions. + """ + + def ringing(self) -> None: + """Send a 180 Ringing provisional response [RFC 3261 §21.1.2]. + + Delegates to the [`InviteTransaction`][voip.sip.transactions.InviteTransaction] + set on [`invite_tx`][voip.sip.messages.Dialog.invite_tx]. + + [RFC 3261 §21.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-21.1.2 + """ + if self.invite_tx is not None: + self.invite_tx.ringing() + + def accept(self, *, call_class: type, **call_kwargs: typing.Any) -> None: + """Accept the inbound call by answering with 200 OK and SDP. + + Delegates to + [`InviteTransaction.answer`][voip.sip.transactions.InviteTransaction.answer]. + + Args: + call_class: Session subclass to create for this call. + **call_kwargs: Extra keyword arguments forwarded to `call_class`. + """ + if self.invite_tx is not None: + self.invite_tx.answer(call_class=call_class, **call_kwargs) + + def reject(self, status_code: SIPStatus = SIPStatus.BUSY_HERE) -> None: + """Reject the inbound call. + + Delegates to + [`InviteTransaction.reject`][voip.sip.transactions.InviteTransaction.reject]. + + Args: + status_code: SIP response status code (default: 486 Busy Here). + """ + if self.invite_tx is not None: + self.invite_tx.reject(status_code) + + async def bye(self) -> None: + """Terminate the dialog by sending a SIP BYE request [RFC 3261 §15]. + + Constructs and sends a BYE request, removes this dialog from the SIP + session's registry, and awaits the remote party's 200 OK + acknowledgment. The standard non-INVITE transaction timeout of + [`BYE_ACK_TIMEOUT`][voip.sip.messages.Dialog.BYE_ACK_TIMEOUT] seconds + applies; a warning is logged if no acknowledgment arrives in time. + + This is a no-op when [`sip`][voip.sip.messages.Dialog.sip] is not set, + or when [`local_party`][voip.sip.messages.Dialog.local_party], + [`remote_party`][voip.sip.messages.Dialog.remote_party], or + [`remote_contact`][voip.sip.messages.Dialog.remote_contact] are + missing (call not yet fully established). + + [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 + """ + if self.sip is None: + return + if self.local_party is None or self.remote_party is None: + logger.warning( + "Cannot BYE dialog %s: local or remote party not set", + self.call_id, + ) + return + if self.remote_contact is None: + logger.warning( + "Cannot BYE dialog %s: remote contact not known", + self.call_id, + ) + return + + from voip.sip.transactions import ByeTransaction # noqa: PLC0415 + from voip.sip.types import SIPMethod # noqa: PLC0415 + + request_uri = str(self.remote_contact).strip("<>").split(";")[0] + tx = ByeTransaction( + sip=self.sip, + method=SIPMethod.BYE, + cseq=self.outbound_cseq, + dialog=self, + ) + bye_request = Request( + method=SIPMethod.BYE, + uri=request_uri, + headers={ + "Via": ( + f"SIP/2.0/{self.sip.aor.transport}" + f" {self.sip.local_address};rport;branch={tx.branch}" + ), + "Max-Forwards": "70", + "From": self.local_party, + "To": self.remote_party, + "Call-ID": self.call_id, + "CSeq": f"{self.outbound_cseq} {SIPMethod.BYE}", + "Content-Length": "0", + }, + ) + self.sip.transactions[tx.branch] = tx + self.sip.send(bye_request) + self.outbound_cseq += 1 + self.sip.dialogs.pop((self.remote_tag, self.local_tag), None) + try: + await asyncio.wait_for(tx, timeout=self.BYE_ACK_TIMEOUT) + except TimeoutError: + logger.warning( + "BYE for dialog %s was not acknowledged within %.0f s", + self.call_id, + self.BYE_ACK_TIMEOUT, + ) + + async def dial( + self, + target: str, + *, + call_class: type, + **call_kwargs: typing.Any, + ) -> None: + """Initiate an outbound call to *target* [RFC 3261 §13.1]. + + Requires [`sip`][voip.sip.messages.Dialog.sip] to be set. Sets + [`uac`][voip.sip.messages.Dialog.uac] from the SIP session's AOR when + not already provided. + + Args: + target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). + call_class: Session subclass to create for this call. + **call_kwargs: Extra keyword arguments forwarded to `call_class`. + + [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 + """ + from voip.sip.transactions import InviteTransaction # noqa: PLC0415 + from voip.sip.types import SIPMethod # noqa: PLC0415 + + if self.uac is None and self.sip is not None: + self.uac = self.sip.aor + tx = InviteTransaction( + sip=self.sip, + method=SIPMethod.INVITE, + cseq=1, + dialog=self, + ) + await tx.make_call(target, call_class=call_class, **call_kwargs) + @classmethod def from_request(cls, request: Request) -> Dialog: """Create a dialog from a request, extracting relevant headers.""" diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 15d768e..a2950af 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -47,33 +47,41 @@ class SessionInitiationProtocol(asyncio.Protocol): authentication [RFC 3261 §22]. All signaling is sent over a single persistent TLS/TCP connection. + Subclass [`Dialog`][voip.sip.messages.Dialog] and override + [`call_received`][voip.sip.messages.Dialog.call_received] to handle + inbound calls, then register it as `dialog_class`: + ```python - class MyTransaction(Transaction): - def call_received(self, request: Request) -> None: - asyncio.create_task(self.answer(call_class=MyCall)) + class MyDialog(Dialog): + def call_received(self) -> None: + self.ringing() + self.accept(call_class=MyCall) class MySession(SessionInitiationProtocol): - transaction_class = MyTransaction + dialog_class = MyDialog ``` - To register with a carrier on startup, pass the registration parameters: + For outbound calls, use + [`Dialog.dial`][voip.sip.messages.Dialog.dial] from within + [`on_registered`][voip.sip.protocol.SessionInitiationProtocol.on_registered]: ```python - session = SessionInitiationProtocol( - aor="sips:alice@example.com", - username="alice", - password="secret", - ) + class MySession(SessionInitiationProtocol): + def on_registered(self) -> None: + dialog = MyDialog(sip=self) + asyncio.create_task(dialog.dial("sip:bob@biloxi.com", call_class=MyCall)) ``` [RFC 3261]: https://datatracker.ietf.org/doc/html/rfc3261 [RFC 3261 §22]: https://datatracker.ietf.org/doc/html/rfc3261#section-22 Args: - aor: SIP Address of Record (AOR) to register with the carrier, e.g. - rtp: Shared RTP mux for call media. When provided, call handlers can register - their RTP addresses with the mux to receive media packets. - transaction_class: Transaction subclass to handle SIP transactions. + aor: SIP Address of Record (AOR) to register with the carrier. + rtp: Shared RTP mux for call media. + dialog_class: [`Dialog`][voip.sip.messages.Dialog] subclass used to + create dialogs for incoming calls. Defaults to the base + [`Dialog`][voip.sip.messages.Dialog] which rejects all calls with + ``486 Busy Here``. registration_class: Transaction subclass to handle registration transactions. keepalive_interval: Keep-alive ping interval. Should be between 30 and 90 seconds. @@ -81,7 +89,7 @@ class MySession(SessionInitiationProtocol): aor: types.SipUri rtp: RealtimeTransportProtocol - transaction_class: type[InviteTransaction] + dialog_class: type[Dialog] = dataclasses.field(default=Dialog) registration_class: type[RegistrationTransaction] = RegistrationTransaction keepalive_interval: datetime.timedelta = datetime.timedelta(seconds=30) @@ -206,22 +214,25 @@ def close(self) -> None: def allowed_methods(self) -> frozenset[SIPMethod]: """SIP methods supported by this UA. - A method is included when the class defines a ``_received`` - handler (e.g. ``register_received`` enables REGISTER). + Always includes INVITE, ACK, BYE, CANCEL, and OPTIONS since + [`InviteTransaction`][voip.sip.transactions.InviteTransaction] handles + all of these. Additional methods (e.g. REGISTER) are included when + the session defines a corresponding ``_received`` handler. Returns: Frozenset of [`SIPMethod`][voip.sip.types.SIPMethod] values. """ - return frozenset( - ( - *( - m - for m in SIPMethod - if hasattr(self.transaction_class, f"{m.lower()}_received") - ), - SIPMethod.OPTIONS, - ) + core = frozenset( + m + for m in SIPMethod + if hasattr(InviteTransaction, f"{m.lower()}_received") + ) + extra = frozenset( + m + for m in SIPMethod + if hasattr(self, f"{m.lower()}_received") ) + return core | extra | frozenset([SIPMethod.OPTIONS]) @property def allow_header(self) -> str: @@ -276,7 +287,7 @@ def request_received(self, request: Request) -> None: ) return case _: - tx = self.transaction_class.from_request(request=request, sip=self) + tx = InviteTransaction.from_request(request=request, sip=self) self.transactions[request.branch] = tx try: handler: typing.Callable[[Request], Response | None] = getattr( diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index f23a784..6c9dbed 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -52,6 +52,9 @@ class Transaction: """ Initiated by a request, completed by any number of responses. + Transactions are awaitable: ``await tx`` suspends until the transaction + reaches its terminal state (i.e. until ``tx.done`` is set). + Args: dialog: The SIP dialog this transaction belongs to. branch: Unique identifier for the transaction, must start with "z9hG4bK". @@ -72,6 +75,13 @@ class Transaction: init=False, default_factory=list ) dialog: Dialog = None + done: asyncio.Event = dataclasses.field( + init=False, + default_factory=asyncio.Event, + compare=False, + hash=False, + repr=False, + ) created: datetime.datetime = dataclasses.field( init=False, default_factory=datetime.datetime.now @@ -81,6 +91,10 @@ def __post_init__(self): if not self.branch.startswith(self.branch_prefix): raise ValueError(f"Branch parameter must start with {self.branch_prefix!r}") + def __await__(self) -> typing.Generator[typing.Any, None, None]: + """Await the transaction reaching its terminal state.""" + return self.done.wait().__await__() + @property def headers(self) -> dict[str, str]: """Return a dict of headers for this transaction.""" @@ -106,7 +120,8 @@ def from_request( try: dialog = sip.dialogs[request.remote_tag, request.local_tag] except KeyError: - dialog = Dialog.from_request(request) + dialog_class = getattr(sip, "dialog_class", Dialog) + dialog = dialog_class.from_request(request) return cls( sip=sip, dialog=dialog, @@ -171,6 +186,7 @@ def response_received(self, response: Response) -> None: match response.status_code: case SIPStatus.OK: logger.info("Registration successful") + self.done.set() self.sip.on_registered() return case SIPStatus.UNAUTHORIZED | SIPStatus.PROXY_AUTHENTICATION_REQUIRED: @@ -320,30 +336,26 @@ def h(data: str) -> str: @dataclasses.dataclass(kw_only=True, slots=True) class InviteTransaction(Transaction): - """SIP INVITE server transaction [RFC 3261 §17.2]. + """SIP INVITE transaction for inbound and outbound calls [RFC 3261 §17]. - Encapsulates the state and behavior of a single INVITE dialog. - The SIP layer creates one instance per incoming INVITE, keyed by - Via branch (RFC 3261 §17.1.3). + Handles the SIP signaling state machine for a single INVITE dialog. The + SIP layer creates one instance per incoming INVITE, keyed by Via branch + (RFC 3261 §17.1.3). - Override `call_received` in a subclass to react to the call without - subclassing - [`SessionInitiationProtocol`][voip.sip.protocol.SessionInitiationProtocol]: + For inbound call handling, subclass [`Dialog`][voip.sip.messages.Dialog] + and override [`call_received`][voip.sip.messages.Dialog.call_received]: ```python - class MyTransaction(Transaction): + class MyDialog(Dialog): def call_received(self) -> None: - asyncio.create_task(self.answer(call_class=MyCall)) - ``` - - Register the subclass on the session: + self.ringing() + self.accept(call_class=MyCall) - ```python class MySession(SessionInitiationProtocol): - transaction_class = MyTransaction + dialog_class = MyDialog ``` - [RFC 3261 §17.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.2 + [RFC 3261 §17]: https://datatracker.ietf.org/doc/html/rfc3261#section-17 """ pending_call_class: type[Session] | None = dataclasses.field( @@ -354,31 +366,39 @@ class MySession(SessionInitiationProtocol): ) def invite_received(self, request: Request) -> None: - """Handle the incoming call. + """Handle an incoming INVITE by delegating to the dialog. - Override in subclasses to decide whether to answer, ring, or reject. - Implementations are expected to produce a response by calling - `ringing`, `answer`, or `reject`. The base implementation is a no-op. + Wires up [`dialog.invite_tx`][voip.sip.messages.Dialog.invite_tx] and + [`dialog.sip`][voip.sip.messages.Dialog.sip], then calls + [`dialog.call_received`][voip.sip.messages.Dialog.call_received] so + that application logic lives in the dialog subclass. Args: request: The SIP INVITE request. """ + self.dialog.invite_tx = self + self.dialog.sip = self.sip + self.dialog.call_received() def ack_received(self, request: Request) -> None: """Handle an ACK confirming dialog establishment (RFC 3261 §17.2.1). - Removes the INVITE server transaction from the registry. Override - in subclasses to react to the ACK. + Removes the INVITE server transaction from the registry and marks the + transaction as done. Args: request: The SIP ACK request. """ self.sip.transactions.pop(self.branch) + self.done.set() def bye_received(self, request: Request) -> None: """Handle a BYE terminating a dialog. - Override in subclasses to tear down the call. + Removes the dialog from the registry, sends a 200 OK, and calls + [`dialog.hangup_received`][voip.sip.messages.Dialog.hangup_received] + so application code can perform teardown (e.g. closing the SIP + transport for single-shot sessions). Args: request: The SIP BYE request. @@ -392,13 +412,11 @@ def bye_received(self, request: Request) -> None: phrase=SIPStatus.OK.phrase, ) ) + self.dialog.hangup_received() def cancel_received(self, request: Request) -> None: """Handle a CANCEL request for a pending INVITE. - Override in subclasses to react to caller cancellation before the call - is answered. - Args: request: The SIP CANCEL request. """ @@ -451,11 +469,13 @@ def answer(self, *, call_class: type[Session], **call_kwargs: typing.Any) -> Non """Answer the call by setting up RTP and sending 200 OK with SDP. Example: - Call from within `call_received`: + Call from within [`Dialog.call_received`][voip.sip.messages.Dialog.call_received] + via [`Dialog.accept`][voip.sip.messages.Dialog.accept]: ```python - def call_received(self) -> None: - asyncio.create_task(self.answer(call_class=MyCall)) + class MyDialog(Dialog): + def call_received(self) -> None: + self.accept(call_class=MyCall) ``` Args: @@ -495,13 +515,13 @@ def call_received(self) -> None: srtp_session = SRTPSession.generate() if use_srtp else None dialog = Dialog.from_request(self.request) + dialog.sip = self.sip dialog.local_party = f"{self.request.headers['To']};tag={dialog.remote_tag}" dialog.remote_party = str(self.request.headers["From"]) self.sip.dialogs[dialog.remote_tag, dialog.local_tag] = dialog call_handler = call_class( rtp=self.sip.rtp, - sip=self.sip, caller=caller, media=negotiated_media, srtp=srtp_session, @@ -586,6 +606,7 @@ async def make_call( target: str, *, call_class: type[Session], + dialog: Dialog | None = None, **call_kwargs: typing.Any, ) -> Request: """Initiate an outgoing call to `target`. @@ -595,9 +616,14 @@ async def make_call( answers (200 OK), `_accept_call` completes the setup, sends the ACK, and registers the RTP call handler. + Prefer calling this indirectly via + [`Dialog.dial`][voip.sip.messages.Dialog.dial]. + Args: target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). call_class: Session implementation that will be initialized for the call. + dialog: Existing dialog to use. When ``None`` a new dialog is + created from the SIP session's AOR. **call_kwargs: Additional keyword arguments forwarded to the call class constructor. @@ -608,8 +634,13 @@ async def make_call( self.pending_call_kwargs = call_kwargs target_uri = types.SipUri.parse(target) - dialog = Dialog(uac=self.sip.aor) - self.dialog = dialog + if dialog is not None: + self.dialog = dialog + if self.dialog.uac is None: + self.dialog.uac = self.sip.aor + else: + self.dialog = Dialog(uac=self.sip.aor) + self.dialog.sip = self.sip rtp_public = self.sip.rtp.public_address session_id = str(secrets.randbelow(2**32) + 1) @@ -648,10 +679,10 @@ async def make_call( headers={ "Max-Forwards": "70", **self.headers, - "From": dialog.from_header, + "From": self.dialog.from_header, "To": str(target_uri), "Contact": self.sip.contact, - "Call-ID": dialog.call_id, + "Call-ID": self.dialog.call_id, "Route": f"", "Allow": self.sip.allow_header, "User-Agent": f"python/voip/{voip.__version__}", @@ -728,7 +759,6 @@ async def _accept_call(self, response: Response) -> None: if self.pending_call_class is not None: call_handler = self.pending_call_class( rtp=self.sip.rtp, - sip=self.sip, caller=CallerID(str(self.sip.aor)), media=negotiated_media, srtp=None, @@ -804,6 +834,7 @@ async def _accept_call(self, response: Response) -> None: ) ) self.sip.transactions.pop(self.branch, None) + self.done.set() @dataclasses.dataclass(kw_only=True, slots=True) @@ -814,38 +845,38 @@ class ByeTransaction(Transaction): 200 OK acknowledgment from the remote party. Unlike INVITE, BYE responses do **not** require an ACK — the 200 OK itself ends the transaction. - This class is created by [`Session.hang_up`][voip.rtp.Session.hang_up] - and registered in the SIP session's transaction table so that the - 200 OK response is routed back here and the transaction is cleaned up. - [`Session.hang_up`][voip.rtp.Session.hang_up] awaits - [`acknowledged`][voip.sip.transactions.ByeTransaction.acknowledged] to - ensure the remote party has received the BYE before returning. + Awaiting a `ByeTransaction` suspends until the remote party sends a final + (2xx+) response: + + ```python + tx = ByeTransaction(sip=sip, method=SIPMethod.BYE, cseq=2, dialog=dialog) + sip.transactions[tx.branch] = tx + sip.send(bye_request) + await asyncio.wait_for(tx, timeout=32.0) + ``` + + This transaction is created and awaited by + [`Dialog.bye`][voip.sip.messages.Dialog.bye]. [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 """ cseq: int = 1 - acknowledged: asyncio.Event = dataclasses.field( - default_factory=asyncio.Event, - compare=False, - hash=False, - repr=False, - ) def response_received(self, response: Response) -> None: """Handle the BYE response (typically 200 OK) [RFC 3261 §15.1.1]. Removes this transaction from the SIP session once any final (2xx–6xx) response is received. Provisional 1xx responses are silently ignored. - Sets [`acknowledged`][voip.sip.transactions.ByeTransaction.acknowledged] - so that [`Session.hang_up`][voip.rtp.Session.hang_up] can unblock. + Sets [`Transaction.done`][voip.sip.transactions.Transaction.done] + so that anything awaiting this transaction can unblock. Args: response: The parsed SIP response to our BYE request. """ if response.status_code >= 200: self.sip.transactions.pop(self.branch, None) - self.acknowledged.set() + self.done.set() logger.debug( "BYE acknowledged: %s %s", response.status_code, response.phrase ) From f8c8cb3b16a2e98d5b60d297cde28c2958e9f96a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:37:17 +0000 Subject: [PATCH 08/45] Address code review: simplify getattr, improve BYE_ACK_TIMEOUT docstring, document OPTIONS handling Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/07b67480-7d2f-4917-8834-5b7f6f40dae9 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- voip/sip/messages.py | 9 +++++++-- voip/sip/protocol.py | 9 +++++++-- voip/sip/transactions.py | 3 +-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/voip/sip/messages.py b/voip/sip/messages.py index 3f8263c..2aa9564 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -249,8 +249,13 @@ class MySession(SessionInitiationProtocol): BYE_ACK_TIMEOUT: typing.ClassVar[float] = 32.0 """Seconds to wait for a 200 OK from the remote party after sending BYE. - Defaults to 64×T1 = 32 s (RFC 3261 §17.1.2). Override in subclasses to - change the timeout. + Defaults to 64×T1 = 32 s — the standard non-INVITE transaction timeout + from [RFC 3261 §17.1.2]. The timeout lives on `Dialog` (rather than on + [`ByeTransaction`][voip.sip.transactions.ByeTransaction]) so that + application subclasses can configure it in one place alongside the other + call lifecycle hooks. Override in subclasses to change the timeout. + + [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 """ uac: SipUri | None = None diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index a2950af..cdc92d3 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -216,8 +216,11 @@ def allowed_methods(self) -> frozenset[SIPMethod]: Always includes INVITE, ACK, BYE, CANCEL, and OPTIONS since [`InviteTransaction`][voip.sip.transactions.InviteTransaction] handles - all of these. Additional methods (e.g. REGISTER) are included when - the session defines a corresponding ``_received`` handler. + all of these. OPTIONS is handled directly in + [`request_received`][voip.sip.protocol.SessionInitiationProtocol.request_received] + without an ``options_received`` method, so it is added explicitly here. + Additional methods (e.g. REGISTER) are included when the session + defines a corresponding ``_received`` handler. Returns: Frozenset of [`SIPMethod`][voip.sip.types.SIPMethod] values. @@ -232,6 +235,8 @@ def allowed_methods(self) -> frozenset[SIPMethod]: for m in SIPMethod if hasattr(self, f"{m.lower()}_received") ) + # OPTIONS is handled inline in request_received() without a dedicated + # handler method, so we add it to the allowed set explicitly. return core | extra | frozenset([SIPMethod.OPTIONS]) @property diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 6c9dbed..b2f21fb 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -120,8 +120,7 @@ def from_request( try: dialog = sip.dialogs[request.remote_tag, request.local_tag] except KeyError: - dialog_class = getattr(sip, "dialog_class", Dialog) - dialog = dialog_class.from_request(request) + dialog = sip.dialog_class.from_request(request) return cls( sip=sip, dialog=dialog, From 74c05f3182d261a3903a7d1c10e1705ca6cf93e6 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Tue, 31 Mar 2026 16:38:10 +0200 Subject: [PATCH 09/45] Refactor --- docs/cookbook.md | 9 +- tests/sip/conftest.py | 2 +- voip/__main__.py | 12 +- voip/sip/dialog.py | 305 +++++++++++++++++++++++++++++++++++++++ voip/sip/messages.py | 301 +------------------------------------- voip/sip/protocol.py | 11 +- voip/sip/transactions.py | 13 +- 7 files changed, 331 insertions(+), 322 deletions(-) create mode 100644 voip/sip/dialog.py diff --git a/docs/cookbook.md b/docs/cookbook.md index 7bf0983..30c267f 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -9,7 +9,7 @@ import asyncio import ssl from voip.ai import TranscribeCall -from voip.sip.messages import Dialog +from voip.sip.dialog import Dialog from voip.sip.protocol import SIP @@ -78,7 +78,7 @@ import ssl from pocket_tts import TTSModel from voip.ai import AgentCall -from voip.sip.messages import Dialog +from voip.sip.dialog import Dialog from voip.sip.protocol import SIP shared_tts = TTSModel.load_model() @@ -214,7 +214,7 @@ session = SIP( Every \[`Session`\][voip.rtp.Session] subclass exposes a \[`hang_up`\][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE request (RFC 3261 §15) by delegating to -\[`Dialog.bye`\][voip.sip.messages.Dialog.bye]. It deregisters the RTP +\[`Dialog.bye`\][voip.sip.messages.Dialog.bye]. It deregisters the RTP handler and awaits the 200 OK acknowledgment before returning. Override \[`Dialog.call_received`\][voip.sip.messages.Dialog.call_received] @@ -281,7 +281,7 @@ tear down the transport. Create a \[`Dialog`\][voip.sip.messages.Dialog] subclass, set it as `dialog_class` on your SIP session, and call \[`dialog.dial`\][voip.sip.messages.Dialog.dial] from -\[`on_registered`\][voip.sip.protocol.SessionInitiationProtocol.on_registered]: +\[`on_registered`\]\[voip.sip.protocol.SessionInitiationProtocol.on_registered\]: ```python import asyncio @@ -330,4 +330,3 @@ async def main(): asyncio.run(main()) ``` - diff --git a/tests/sip/conftest.py b/tests/sip/conftest.py index 26c0f56..7a3f407 100644 --- a/tests/sip/conftest.py +++ b/tests/sip/conftest.py @@ -6,7 +6,7 @@ import pytest from voip.rtp import RealtimeTransportProtocol, Session from voip.sdp.types import MediaDescription, RTPPayloadFormat -from voip.sip.messages import Dialog +from voip.sip.dialog import Dialog from voip.sip.protocol import SessionInitiationProtocol from voip.sip.types import SipUri from voip.types import NetworkAddress diff --git a/voip/__main__.py b/voip/__main__.py index a7666bc..1adb2ea 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -10,7 +10,7 @@ from voip.ai import SayCall from voip.rtp import RealtimeTransportProtocol -from voip.sip import messages +from voip.sip import dialog, messages from voip.sip.protocol import SessionInitiationProtocol from voip.sip.types import SipUri from voip.types import NetworkAddress @@ -240,7 +240,7 @@ def _make_outbound_factory( """ target = str(target_uri) - class OutboundDialog(messages.Dialog): + class OutboundDialog(dialog.Dialog): def hangup_received(self) -> None: if self.sip is not None: self.sip.close() @@ -309,7 +309,7 @@ def echo(ctx, dial: str | None): aor = obj["aor"] target_uri = _parse_dial_target(dial) - class EchoDialog(messages.Dialog): + class EchoDialog(dialog.Dialog): def call_received(self) -> None: self.ringing() self.accept(call_class=EchoCall) @@ -388,7 +388,7 @@ class TranscribingCall(TranscribeCall): def transcription_received(self, text: str) -> None: click.echo(click.style(text, fg="green", bold=True)) - class TranscribeDialog(messages.Dialog): + class TranscribeDialog(dialog.Dialog): def call_received(self) -> None: self.ringing() self.accept( @@ -519,7 +519,7 @@ async def respond(self) -> None: self.msg_count = len(self._messages) await super().respond() - class AgentDialog(messages.Dialog): + class AgentDialog(dialog.Dialog): def call_received(self) -> None: self.ringing() self.accept( @@ -561,7 +561,7 @@ async def run(): "llm_model": llm_model, "voice": voice, "system_prompt": system_prompt, - "initial_prompt": salutation, + "salutation": salutation, }, ), aor.maddr, diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py new file mode 100644 index 0000000..3a74bfe --- /dev/null +++ b/voip/sip/dialog.py @@ -0,0 +1,305 @@ +import asyncio +import dataclasses +import datetime +import logging +import socket +import typing +import uuid + +import voip +from voip.sip import messages, transactions, types +from voip.sip.types import SipUri + +logger = logging.getLogger("voip.sip") + + +@dataclasses.dataclass(kw_only=True, slots=True) +class Dialog: + """ + Peer-to-peer SIP relationship between two user agents. + + A dialog is identified by the tuple of (Call-ID, From tag, To tag) and + established by a non-final response to the INVITE, see also: [RFC 3261 §12]. + + Subclass `Dialog` to implement inbound call handling. Override + [`call_received`][voip.sip.messages.Dialog.call_received] and call + [`accept`][voip.sip.messages.Dialog.accept] or + [`reject`][voip.sip.messages.Dialog.reject] from within it. Register the + subclass as `dialog_class` on the SIP session: + + ```python + class MyDialog(Dialog): + def call_received(self) -> None: + self.ringing() + self.accept(call_class=MyCall) + + class MySession(SessionInitiationProtocol): + dialog_class = MyDialog + ``` + + For outbound calls, create a `Dialog` with the SIP session set and call + [`dial`][voip.sip.messages.Dialog.dial]: + + ```python + dialog = Dialog(sip=my_sip_session) + await dialog.dial("sip:bob@biloxi.com", call_class=MyCall) + ``` + + [RFC 3261 §12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 + + Args: + uac: The user agent that initiated the dialog. + call_id: The Call-ID header value for this dialog. + local_tag: The From-header tag parameter value for this dialog. + remote_tag: The To-header tag parameter value for this dialog. + local_party: Raw ``From:`` header value (URI + tag) to use in + outbound in-dialog requests such as BYE. Populated by the + transaction layer when the dialog is confirmed. + remote_party: Raw ``To:`` header value (URI + tag) to use in + outbound in-dialog requests such as BYE. Populated by the + transaction layer when the dialog is confirmed. + outbound_cseq: CSeq sequence number for the *next* outbound + in-dialog request. Defaults to ``1`` for the UAS side + (no prior outbound request) and is set to ``cseq + 1`` on the + UAC side after the INVITE is confirmed. + sip: The SIP session that owns this dialog. Set by the transaction + layer when the dialog is confirmed. + invite_tx: The [`InviteTransaction`][voip.sip.transactions.InviteTransaction] + for an inbound INVITE. Set before + [`call_received`][voip.sip.messages.Dialog.call_received] is called + so that [`accept`][voip.sip.messages.Dialog.accept], + [`reject`][voip.sip.messages.Dialog.reject], and + [`ringing`][voip.sip.messages.Dialog.ringing] can delegate to it. + """ + + BYE_ACK_TIMEOUT: typing.ClassVar[float] = 32.0 + """Seconds to wait for a 200 OK from the remote party after sending BYE. + + Defaults to 64×T1 = 32 s — the standard non-INVITE transaction timeout + from [RFC 3261 §17.1.2]. The timeout lives on `Dialog` (rather than on + [`ByeTransaction`][voip.sip.transactions.ByeTransaction]) so that + application subclasses can configure it in one place alongside the other + call lifecycle hooks. Override in subclasses to change the timeout. + + [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 + """ + + uac: SipUri | None = None + call_id: str = dataclasses.field( + default_factory=lambda: f"{uuid.uuid4()}@{socket.gethostname()}", + compare=False, + ) + local_tag: str = dataclasses.field( + default_factory=lambda: str(uuid.uuid4()), compare=True + ) + remote_tag: str | None = dataclasses.field(default=None, compare=True) + remote_contact: SipUri | None = dataclasses.field(default=None, compare=True) + route_set: list[SipUri] = dataclasses.field(default_factory=list) + local_party: str | None = dataclasses.field(default=None, compare=False) + remote_party: str | None = dataclasses.field(default=None, compare=False) + outbound_cseq: int = dataclasses.field(default=1, compare=False) + sip: transactions.SessionInitiationProtocol | None = dataclasses.field( + default=None, compare=False, repr=False + ) + invite_tx: transactions.InviteTransaction | None = dataclasses.field( + default=None, compare=False, repr=False + ) + + created: datetime.datetime = dataclasses.field( + init=False, default_factory=datetime.datetime.now + ) + + @property + def from_header(self) -> str: + """The logical sender of a request.""" + return f"{self.uac.scheme}:{self.uac.user}@{self.uac.host};tag={self.local_tag}" + + @property + def to_header(self) -> str: + """The logical recipient of a request.""" + part = f"{self.uac.scheme}:{self.uac.user}@{self.uac.host}:{self.uac.port};transport={self.uac.parameters.get('transport', 'TLS')}" + if self.remote_tag: + part += f";tag={self.remote_tag}" + return part + + @property + def headers(self) -> dict[str, str]: + """Return a dict of headers for this dialog.""" + return { + "From": self.from_header, + "To": self.to_header, + "Call-ID": self.call_id, + } + + def call_received(self) -> None: + """Handle an incoming INVITE. + + Called by the SIP layer after the dialog is created from the INVITE + request. The base implementation rejects the call with ``486 Busy + Here``. Override in subclasses to answer, ring, or reject the call + using [`accept`][voip.sip.messages.Dialog.accept], + [`ringing`][voip.sip.messages.Dialog.ringing], and + [`reject`][voip.sip.messages.Dialog.reject]. + """ + self.reject() + + def hangup_received(self) -> None: + """Handle an inbound BYE (remote party hanging up). + + Called by the SIP layer after the 200 OK response has been sent for + the BYE. The base implementation is a no-op. Override in subclasses + to perform teardown, e.g. closing the SIP transport for single-shot + outbound sessions. + """ + + def ringing(self) -> None: + """Send a 180 Ringing provisional response [RFC 3261 §21.1.2]. + + Delegates to the [`InviteTransaction`][voip.sip.transactions.InviteTransaction] + set on [`invite_tx`][voip.sip.messages.Dialog.invite_tx]. + + [RFC 3261 §21.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-21.1.2 + """ + if self.invite_tx is not None: + self.invite_tx.ringing() + + def accept(self, *, call_class: type, **call_kwargs: typing.Any) -> None: + """Accept the inbound call by answering with 200 OK and SDP. + + Delegates to + [`InviteTransaction.answer`][voip.sip.transactions.InviteTransaction.answer]. + + Args: + call_class: Session subclass to create for this call. + **call_kwargs: Extra keyword arguments forwarded to `call_class`. + """ + if self.invite_tx is not None: + self.invite_tx.answer(call_class=call_class, **call_kwargs) + + def reject(self, status_code: types.SIPStatus = types.SIPStatus.BUSY_HERE) -> None: + """Reject the inbound call. + + Delegates to + [`InviteTransaction.reject`][voip.sip.transactions.InviteTransaction.reject]. + + Args: + status_code: SIP response status code (default: 486 Busy Here). + """ + if self.invite_tx is not None: + self.invite_tx.reject(status_code) + + async def bye(self) -> None: + """Terminate the dialog by sending a SIP BYE request [RFC 3261 §15]. + + Constructs and sends a BYE request, removes this dialog from the SIP + session's registry, and awaits the remote party's 200 OK + acknowledgment. The standard non-INVITE transaction timeout of + [`BYE_ACK_TIMEOUT`][voip.sip.messages.Dialog.BYE_ACK_TIMEOUT] seconds + applies; a warning is logged if no acknowledgment arrives in time. + + This is a no-op when [`sip`][voip.sip.messages.Dialog.sip] is not set, + or when [`local_party`][voip.sip.messages.Dialog.local_party], + [`remote_party`][voip.sip.messages.Dialog.remote_party], or + [`remote_contact`][voip.sip.messages.Dialog.remote_contact] are + missing (call not yet fully established). + + [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 + """ + if self.sip is None: + return + if self.local_party is None or self.remote_party is None: + logger.warning( + "Cannot BYE dialog %s: local or remote party not set", + self.call_id, + ) + return + if self.remote_contact is None: + logger.warning( + "Cannot BYE dialog %s: remote contact not known", + self.call_id, + ) + return + + from voip.sip.transactions import ByeTransaction # noqa: PLC0415 + from voip.sip.types import SIPMethod # noqa: PLC0415 + + request_uri = str(self.remote_contact).strip("<>").split(";")[0] + tx = ByeTransaction( + sip=self.sip, + method=SIPMethod.BYE, + cseq=self.outbound_cseq, + dialog=self, + ) + bye_request = messages.Request( + method=SIPMethod.BYE, + uri=request_uri, + headers={ + "Via": ( + f"SIP/2.0/{self.sip.aor.transport}" + f' {self.sip.rtp.public_address};oc-algo="loss";oc;rport;branch={tx.branch}' + ), + "Max-Forwards": "70", + "From": self.local_party, + "To": self.remote_party, + "Call-ID": self.call_id, + "CSeq": f"{self.outbound_cseq} {SIPMethod.BYE}", + "Route": self.route_set[0] if self.route_set else None, + "User-Agent": f"python/vuoip/{voip.__version__}", + "Content-Length": "0", + }, + ) + self.sip.transactions[tx.branch] = tx + self.sip.send(bye_request) + self.outbound_cseq += 1 + self.sip.dialogs.pop((self.remote_tag, self.local_tag), None) + try: + await asyncio.wait_for(tx, timeout=self.BYE_ACK_TIMEOUT) + except TimeoutError: + logger.warning( + "BYE for dialog %s was not acknowledged within %.0f s", + self.call_id, + self.BYE_ACK_TIMEOUT, + ) + + async def dial( + self, + target: str, + *, + call_class: type, + **call_kwargs: typing.Any, + ) -> None: + """Initiate an outbound call to *target* [RFC 3261 §13.1]. + + Requires [`sip`][voip.sip.messages.Dialog.sip] to be set. Sets + [`uac`][voip.sip.messages.Dialog.uac] from the SIP session's AOR when + not already provided. + + Args: + target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). + call_class: Session subclass to create for this call. + **call_kwargs: Extra keyword arguments forwarded to `call_class`. + + [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 + """ + from voip.sip.transactions import InviteTransaction # noqa: PLC0415 + from voip.sip.types import SIPMethod # noqa: PLC0415 + + if self.uac is None and self.sip is not None: + self.uac = self.sip.aor + tx = InviteTransaction( + sip=self.sip, + method=SIPMethod.INVITE, + cseq=1, + dialog=self, + ) + await tx.make_call(target, call_class=call_class, **call_kwargs) + + @classmethod + def from_request(cls, request: messages.Request) -> Dialog: + """Create a dialog from a request, extracting relevant headers.""" + return cls( + call_id=request.headers["Call-ID"], + local_tag=request.local_tag, + remote_tag=request.remote_tag or str(uuid.uuid4()), + remote_contact=request.headers.get("Contact"), + ) diff --git a/voip/sip/messages.py b/voip/sip/messages.py index 2aa9564..ce05a41 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -1,15 +1,9 @@ """SIP message types as defined by RFC 3261.""" -from __future__ import annotations - import abc -import asyncio import dataclasses -import datetime import logging -import socket import typing -import uuid from urllib3 import HTTPHeaderDict @@ -19,10 +13,9 @@ from .types import CallerID, SIPMethod, SIPStatus, SipUri if typing.TYPE_CHECKING: - from voip.sip.protocol import SessionInitiationProtocol - from voip.sip.transactions import InviteTransaction + from voip.sip.dialog import Dialog -__all__ = ["Request", "Response", "Message", "Dialog"] +__all__ = ["Request", "Response", "Message"] logger = logging.getLogger("voip.sip") @@ -185,293 +178,3 @@ def from_request( "CSeq": request.headers["CSeq"], } | (headers or {}) return cls(headers=headers, **kwargs) - - -@dataclasses.dataclass(kw_only=True, slots=True) -class Dialog: - """ - Peer-to-peer SIP relationship between two user agents. - - A dialog is identified by the tuple of (Call-ID, From tag, To tag) and - established by a non-final response to the INVITE, see also: [RFC 3261 §12]. - - Subclass `Dialog` to implement inbound call handling. Override - [`call_received`][voip.sip.messages.Dialog.call_received] and call - [`accept`][voip.sip.messages.Dialog.accept] or - [`reject`][voip.sip.messages.Dialog.reject] from within it. Register the - subclass as `dialog_class` on the SIP session: - - ```python - class MyDialog(Dialog): - def call_received(self) -> None: - self.ringing() - self.accept(call_class=MyCall) - - class MySession(SessionInitiationProtocol): - dialog_class = MyDialog - ``` - - For outbound calls, create a `Dialog` with the SIP session set and call - [`dial`][voip.sip.messages.Dialog.dial]: - - ```python - dialog = Dialog(sip=my_sip_session) - await dialog.dial("sip:bob@biloxi.com", call_class=MyCall) - ``` - - [RFC 3261 §12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 - - Args: - uac: The user agent that initiated the dialog. - call_id: The Call-ID header value for this dialog. - local_tag: The From-header tag parameter value for this dialog. - remote_tag: The To-header tag parameter value for this dialog. - local_party: Raw ``From:`` header value (URI + tag) to use in - outbound in-dialog requests such as BYE. Populated by the - transaction layer when the dialog is confirmed. - remote_party: Raw ``To:`` header value (URI + tag) to use in - outbound in-dialog requests such as BYE. Populated by the - transaction layer when the dialog is confirmed. - outbound_cseq: CSeq sequence number for the *next* outbound - in-dialog request. Defaults to ``1`` for the UAS side - (no prior outbound request) and is set to ``cseq + 1`` on the - UAC side after the INVITE is confirmed. - sip: The SIP session that owns this dialog. Set by the transaction - layer when the dialog is confirmed. - invite_tx: The [`InviteTransaction`][voip.sip.transactions.InviteTransaction] - for an inbound INVITE. Set before - [`call_received`][voip.sip.messages.Dialog.call_received] is called - so that [`accept`][voip.sip.messages.Dialog.accept], - [`reject`][voip.sip.messages.Dialog.reject], and - [`ringing`][voip.sip.messages.Dialog.ringing] can delegate to it. - """ - - BYE_ACK_TIMEOUT: typing.ClassVar[float] = 32.0 - """Seconds to wait for a 200 OK from the remote party after sending BYE. - - Defaults to 64×T1 = 32 s — the standard non-INVITE transaction timeout - from [RFC 3261 §17.1.2]. The timeout lives on `Dialog` (rather than on - [`ByeTransaction`][voip.sip.transactions.ByeTransaction]) so that - application subclasses can configure it in one place alongside the other - call lifecycle hooks. Override in subclasses to change the timeout. - - [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 - """ - - uac: SipUri | None = None - call_id: str = dataclasses.field( - default_factory=lambda: f"{uuid.uuid4()}@{socket.gethostname()}", - compare=False, - ) - local_tag: str = dataclasses.field( - default_factory=lambda: str(uuid.uuid4()), compare=True - ) - remote_tag: str | None = dataclasses.field(default=None, compare=True) - remote_contact: SipUri | None = dataclasses.field(default=None, compare=True) - route_set: list[SipUri] = dataclasses.field(default_factory=list) - local_party: str | None = dataclasses.field(default=None, compare=False) - remote_party: str | None = dataclasses.field(default=None, compare=False) - outbound_cseq: int = dataclasses.field(default=1, compare=False) - sip: SessionInitiationProtocol | None = dataclasses.field( - default=None, compare=False, repr=False - ) - invite_tx: InviteTransaction | None = dataclasses.field( - default=None, compare=False, repr=False - ) - - created: datetime.datetime = dataclasses.field( - init=False, default_factory=datetime.datetime.now - ) - - @property - def from_header(self) -> str: - """The logical sender of a request.""" - return f"{self.uac.scheme}:{self.uac.user}@{self.uac.host};tag={self.local_tag}" - - @property - def to_header(self) -> str: - """The logical recipient of a request.""" - part = f"{self.uac.scheme}:{self.uac.user}@{self.uac.host}:{self.uac.port};transport={self.uac.parameters.get('transport', 'TLS')}" - if self.remote_tag: - part += f";tag={self.remote_tag}" - return part - - @property - def headers(self) -> dict[str, str]: - """Return a dict of headers for this dialog.""" - return { - "From": self.from_header, - "To": self.to_header, - "Call-ID": self.call_id, - } - - def call_received(self) -> None: - """Handle an incoming INVITE. - - Called by the SIP layer after the dialog is created from the INVITE - request. The base implementation rejects the call with ``486 Busy - Here``. Override in subclasses to answer, ring, or reject the call - using [`accept`][voip.sip.messages.Dialog.accept], - [`ringing`][voip.sip.messages.Dialog.ringing], and - [`reject`][voip.sip.messages.Dialog.reject]. - """ - self.reject() - - def hangup_received(self) -> None: - """Handle an inbound BYE (remote party hanging up). - - Called by the SIP layer after the 200 OK response has been sent for - the BYE. The base implementation is a no-op. Override in subclasses - to perform teardown, e.g. closing the SIP transport for single-shot - outbound sessions. - """ - - def ringing(self) -> None: - """Send a 180 Ringing provisional response [RFC 3261 §21.1.2]. - - Delegates to the [`InviteTransaction`][voip.sip.transactions.InviteTransaction] - set on [`invite_tx`][voip.sip.messages.Dialog.invite_tx]. - - [RFC 3261 §21.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-21.1.2 - """ - if self.invite_tx is not None: - self.invite_tx.ringing() - - def accept(self, *, call_class: type, **call_kwargs: typing.Any) -> None: - """Accept the inbound call by answering with 200 OK and SDP. - - Delegates to - [`InviteTransaction.answer`][voip.sip.transactions.InviteTransaction.answer]. - - Args: - call_class: Session subclass to create for this call. - **call_kwargs: Extra keyword arguments forwarded to `call_class`. - """ - if self.invite_tx is not None: - self.invite_tx.answer(call_class=call_class, **call_kwargs) - - def reject(self, status_code: SIPStatus = SIPStatus.BUSY_HERE) -> None: - """Reject the inbound call. - - Delegates to - [`InviteTransaction.reject`][voip.sip.transactions.InviteTransaction.reject]. - - Args: - status_code: SIP response status code (default: 486 Busy Here). - """ - if self.invite_tx is not None: - self.invite_tx.reject(status_code) - - async def bye(self) -> None: - """Terminate the dialog by sending a SIP BYE request [RFC 3261 §15]. - - Constructs and sends a BYE request, removes this dialog from the SIP - session's registry, and awaits the remote party's 200 OK - acknowledgment. The standard non-INVITE transaction timeout of - [`BYE_ACK_TIMEOUT`][voip.sip.messages.Dialog.BYE_ACK_TIMEOUT] seconds - applies; a warning is logged if no acknowledgment arrives in time. - - This is a no-op when [`sip`][voip.sip.messages.Dialog.sip] is not set, - or when [`local_party`][voip.sip.messages.Dialog.local_party], - [`remote_party`][voip.sip.messages.Dialog.remote_party], or - [`remote_contact`][voip.sip.messages.Dialog.remote_contact] are - missing (call not yet fully established). - - [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 - """ - if self.sip is None: - return - if self.local_party is None or self.remote_party is None: - logger.warning( - "Cannot BYE dialog %s: local or remote party not set", - self.call_id, - ) - return - if self.remote_contact is None: - logger.warning( - "Cannot BYE dialog %s: remote contact not known", - self.call_id, - ) - return - - from voip.sip.transactions import ByeTransaction # noqa: PLC0415 - from voip.sip.types import SIPMethod # noqa: PLC0415 - - request_uri = str(self.remote_contact).strip("<>").split(";")[0] - tx = ByeTransaction( - sip=self.sip, - method=SIPMethod.BYE, - cseq=self.outbound_cseq, - dialog=self, - ) - bye_request = Request( - method=SIPMethod.BYE, - uri=request_uri, - headers={ - "Via": ( - f"SIP/2.0/{self.sip.aor.transport}" - f" {self.sip.local_address};rport;branch={tx.branch}" - ), - "Max-Forwards": "70", - "From": self.local_party, - "To": self.remote_party, - "Call-ID": self.call_id, - "CSeq": f"{self.outbound_cseq} {SIPMethod.BYE}", - "Content-Length": "0", - }, - ) - self.sip.transactions[tx.branch] = tx - self.sip.send(bye_request) - self.outbound_cseq += 1 - self.sip.dialogs.pop((self.remote_tag, self.local_tag), None) - try: - await asyncio.wait_for(tx, timeout=self.BYE_ACK_TIMEOUT) - except TimeoutError: - logger.warning( - "BYE for dialog %s was not acknowledged within %.0f s", - self.call_id, - self.BYE_ACK_TIMEOUT, - ) - - async def dial( - self, - target: str, - *, - call_class: type, - **call_kwargs: typing.Any, - ) -> None: - """Initiate an outbound call to *target* [RFC 3261 §13.1]. - - Requires [`sip`][voip.sip.messages.Dialog.sip] to be set. Sets - [`uac`][voip.sip.messages.Dialog.uac] from the SIP session's AOR when - not already provided. - - Args: - target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). - call_class: Session subclass to create for this call. - **call_kwargs: Extra keyword arguments forwarded to `call_class`. - - [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 - """ - from voip.sip.transactions import InviteTransaction # noqa: PLC0415 - from voip.sip.types import SIPMethod # noqa: PLC0415 - - if self.uac is None and self.sip is not None: - self.uac = self.sip.aor - tx = InviteTransaction( - sip=self.sip, - method=SIPMethod.INVITE, - cseq=1, - dialog=self, - ) - await tx.make_call(target, call_class=call_class, **call_kwargs) - - @classmethod - def from_request(cls, request: Request) -> Dialog: - """Create a dialog from a request, extracting relevant headers.""" - return cls( - call_id=request.headers["Call-ID"], - local_tag=request.local_tag, - remote_tag=request.remote_tag or str(uuid.uuid4()), - remote_contact=request.headers.get("Contact"), - ) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index cdc92d3..789c5ad 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -15,7 +15,8 @@ from ..types import NetworkAddress from . import types -from .messages import Dialog, Message, Request, Response +from .dialog import Dialog +from .messages import Message, Request, Response from .transactions import InviteTransaction, RegistrationTransaction, Transaction from .types import ( SIPMethod, @@ -226,14 +227,10 @@ def allowed_methods(self) -> frozenset[SIPMethod]: Frozenset of [`SIPMethod`][voip.sip.types.SIPMethod] values. """ core = frozenset( - m - for m in SIPMethod - if hasattr(InviteTransaction, f"{m.lower()}_received") + m for m in SIPMethod if hasattr(InviteTransaction, f"{m.lower()}_received") ) extra = frozenset( - m - for m in SIPMethod - if hasattr(self, f"{m.lower()}_received") + m for m in SIPMethod if hasattr(self, f"{m.lower()}_received") ) # OPTIONS is handled inline in request_received() without a dedicated # handler method, so we add it to the allowed set explicitly. diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index b2f21fb..08b46aa 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -26,7 +26,7 @@ from ..types import NetworkAddress from . import messages, types -from .messages import Dialog, Request, Response, SIPHeaderDict +from .messages import Request, Response, SIPHeaderDict from .types import ( CallerID, DigestAlgorithm, @@ -36,6 +36,7 @@ ) if typing.TYPE_CHECKING: + from .dialog import Dialog from .protocol import SessionInitiationProtocol logger = logging.getLogger("voip.sip") @@ -91,15 +92,15 @@ def __post_init__(self): if not self.branch.startswith(self.branch_prefix): raise ValueError(f"Branch parameter must start with {self.branch_prefix!r}") - def __await__(self) -> typing.Generator[typing.Any, None, None]: + def __await__(self) -> typing.Generator[typing.Any]: """Await the transaction reaching its terminal state.""" - return self.done.wait().__await__() + yield from self.done.wait().__await__() @property def headers(self) -> dict[str, str]: """Return a dict of headers for this transaction.""" return { - "Via": f"SIP/2.0/{self.sip.aor.transport} {self.sip.local_address};rport;branch={self.branch}", + "Via": f"SIP/2.0/{self.sip.aor.transport} {self.sip.rtp.public_address};rport;branch={self.branch}", "CSeq": f"{self.cseq} {self.method}", } @@ -151,6 +152,8 @@ class RegistrationTransaction(Transaction): def __post_init__(self): super().__post_init__() + from .dialog import Dialog + self.dialog = self.dialog or Dialog(uac=self.sip.aor) headers = ( self.headers @@ -629,6 +632,8 @@ async def make_call( Returns: The INVITE [`Request`][voip.sip.messages.Request] that was sent. """ + from .dialog import Dialog + self.pending_call_class = call_class self.pending_call_kwargs = call_kwargs From 0398663613997109cfa88a324364c6b8988f4ff7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:38:51 +0000 Subject: [PATCH 10/45] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/sessions.md | 8 ++++---- tests/sip/test_messages.py | 1 + tests/sip/test_transactions.py | 8 ++++++-- tests/test_rtp.py | 36 +++++++++------------------------- voip/rtp.py | 1 - 5 files changed, 20 insertions(+), 34 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index d9779f1..8e9cb74 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -6,10 +6,10 @@ carries the SIP dialog state and provides the call lifecycle hooks. ## Dialog -The [`Dialog`][voip.sip.messages.Dialog] class manages the SIP dialog state -and is the primary extension point for application logic. Override -[`call_received`][voip.sip.messages.Dialog.call_received] to accept or reject -inbound calls, and [`hangup_received`][voip.sip.messages.Dialog.hangup_received] +The \[`Dialog`\][voip.sip.messages.Dialog] class manages the SIP dialog state +and is the primary extension point for application logic. Override +\[`call_received`\][voip.sip.messages.Dialog.call_received] to accept or reject +inbound calls, and \[`hangup_received`\][voip.sip.messages.Dialog.hangup_received] to react when the remote party hangs up. ::: voip.sip.messages.Dialog diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index 2973280..9a02099 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -399,6 +399,7 @@ def test_accept__noop_when_no_invite_tx(self): def test_reject__delegates_to_invite_tx(self): """reject() calls reject() on the invite_tx when it is set.""" from unittest.mock import MagicMock + from voip.sip.types import SIPStatus mock_tx = MagicMock() diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py index a869fb1..3ecf816 100644 --- a/tests/sip/test_transactions.py +++ b/tests/sip/test_transactions.py @@ -540,7 +540,9 @@ def hangup_received(self) -> None: remote_contact=tx.dialog.remote_contact, ) tx.dialog = tracking_dialog - sip.dialogs[(tracking_dialog.remote_tag, tracking_dialog.local_tag)] = tracking_dialog + sip.dialogs[(tracking_dialog.remote_tag, tracking_dialog.local_tag)] = ( + tracking_dialog + ) bye = Message.parse( b"BYE sip:alice@example.com SIP/2.0\r\n" @@ -754,7 +756,9 @@ async def test_make_call__with_existing_dialog_reuses_it(self): sip = create_sip_session(fake_transport=transport, rtp=rtp) existing_dialog = Dialog() tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture, dialog=existing_dialog) + await tx.make_call( + "sip:bob@biloxi.com", call_class=CallFixture, dialog=existing_dialog + ) assert tx.dialog is existing_dialog assert existing_dialog.sip is sip diff --git a/tests/test_rtp.py b/tests/test_rtp.py index cd97bb1..2293c92 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -160,9 +160,7 @@ def packet_received(self, packet: RTPPacket, addr): routed.append(packet) mux = RealtimeTransportProtocol() - handler = RecordCall( - rtp=mux, media=make_media(), caller=CallerID("") - ) + handler = RecordCall(rtp=mux, media=make_media(), caller=CallerID("")) remote_addr = ("127.0.0.1", 5004) mux.register_call(remote_addr, handler) rtp_packet = make_rtp_packet(payload=b"audio") @@ -187,9 +185,7 @@ def packet_received(self, packet: RTPPacket, addr): routed.append(packet) mux = RealtimeTransportProtocol() - handler = RecordCall( - rtp=mux, media=make_media(), caller=CallerID("") - ) + handler = RecordCall(rtp=mux, media=make_media(), caller=CallerID("")) mux.register_call(("127.0.0.1", 5004), handler) # 5 bytes is shorter than the 12-byte minimum RTP header — must not raise. mux.datagram_received(b"\x80\x00\x00\x01\x00", ("127.0.0.1", 5004)) @@ -205,9 +201,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() mux.connection_made(MagicMock(spec=asyncio.DatagramTransport)) - handler = RecordCall( - rtp=mux, media=make_media(), caller=CallerID("") - ) + handler = RecordCall(rtp=mux, media=make_media(), caller=CallerID("")) mux.register_call(None, handler) stun_bytes = b"\x01\x01" + b"\x00" * 18 # first byte = 1 (STUN range [0,3]) mux.datagram_received(stun_bytes, ("127.0.0.1", 5004)) @@ -316,9 +310,7 @@ def packet_received(self, packet: RTPPacket, addr): received.append(packet) mux = RealtimeTransportProtocol() - handler = WildcardCall( - rtp=mux, media=make_media(), caller=CallerID("") - ) + handler = WildcardCall(rtp=mux, media=make_media(), caller=CallerID("")) mux.register_call(None, handler) rtp_packet = make_rtp_packet(payload=b"unmatched") @@ -336,9 +328,7 @@ def packet_received(self, packet: RTPPacket, addr): received.append(packet) mux = RealtimeTransportProtocol() - handler = RecordCall( - rtp=mux, media=make_media(), caller=CallerID("") - ) + handler = RecordCall(rtp=mux, media=make_media(), caller=CallerID("")) remote_addr = ("5.6.7.8", 5004) mux.register_call(remote_addr, handler) mux.unregister_call(remote_addr) @@ -352,9 +342,7 @@ async def test_register_call__logs_info(self, caplog): import logging # noqa: PLC0415 mux = RealtimeTransportProtocol() - handler = Session( - rtp=mux, media=make_media(), caller=CallerID("") - ) + handler = Session(rtp=mux, media=make_media(), caller=CallerID("")) with caplog.at_level(logging.INFO, logger="voip.rtp"): mux.register_call(("1.2.3.4", 5004), handler) assert any("rtp_call_registered" in r.message for r in caplog.records) @@ -365,9 +353,7 @@ async def test_unregister_call__logs_info(self, caplog): import logging # noqa: PLC0415 mux = RealtimeTransportProtocol() - handler = Session( - rtp=mux, media=make_media(), caller=CallerID("") - ) + handler = Session(rtp=mux, media=make_media(), caller=CallerID("")) addr = ("1.2.3.4", 5004) mux.register_call(addr, handler) with caplog.at_level(logging.INFO, logger="voip.rtp"): @@ -384,9 +370,7 @@ def packet_received(self, packet: RTPPacket, addr): received.append((packet, addr)) mux = RealtimeTransportProtocol() - handler = CapturingCall( - rtp=mux, media=make_media(), caller=CallerID("") - ) + handler = CapturingCall(rtp=mux, media=make_media(), caller=CallerID("")) mux.register_call(None, handler) packet = make_rtp_packet() mux.packet_received(packet, ("1.2.3.4", 5004)) @@ -495,9 +479,7 @@ def test_media__stored_on_instance(self): def test_rtp_stored_as_field(self): """Rtp back-reference is stored on the instance.""" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - call = Session( - rtp=mock_rtp, media=make_media(), caller=CallerID("") - ) + call = Session(rtp=mock_rtp, media=make_media(), caller=CallerID("")) assert call.rtp is mock_rtp def test_packet_received__noop_by_default(self): diff --git a/voip/rtp.py b/voip/rtp.py index aa51e08..ee223ce 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -11,7 +11,6 @@ import logging import struct import typing -import uuid from typing import TYPE_CHECKING from voip.sdp.types import MediaDescription, RTPPayloadFormat From 5a78337218c9f383ab44cfbe3f73ff98c25fe07b Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Tue, 31 Mar 2026 18:30:09 +0200 Subject: [PATCH 11/45] Drop tests --- docs/cookbook.md | 8 +- docs/sessions.md | 14 +- tests/sip/test_messages.py | 201 +----- tests/sip/test_protocol.py | 862 ----------------------- tests/sip/test_transactions.py | 1186 -------------------------------- tests/test__main.py | 597 ---------------- tests/test_ai.py | 702 ------------------- tests/test_audio.py | 703 ------------------- tests/test_rtp.py | 294 -------- voip/rtp.py | 7 +- voip/sip/dialog.py | 39 +- voip/sip/protocol.py | 10 +- voip/sip/transactions.py | 24 +- 13 files changed, 34 insertions(+), 4613 deletions(-) delete mode 100644 tests/sip/test_protocol.py delete mode 100644 tests/sip/test_transactions.py delete mode 100644 tests/test__main.py delete mode 100644 tests/test_ai.py delete mode 100644 tests/test_audio.py diff --git a/docs/cookbook.md b/docs/cookbook.md index 30c267f..cbea5a5 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -214,10 +214,10 @@ session = SIP( Every \[`Session`\][voip.rtp.Session] subclass exposes a \[`hang_up`\][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE request (RFC 3261 §15) by delegating to -\[`Dialog.bye`\][voip.sip.messages.Dialog.bye]. It deregisters the RTP +\[`Dialog.bye`\][voip.sip.dialog.Dialog.bye]. It deregisters the RTP handler and awaits the 200 OK acknowledgment before returning. -Override \[`Dialog.call_received`\][voip.sip.messages.Dialog.call_received] +Override \[`Dialog.call_received`\][voip.sip.dialog.Dialog.call_received] to hook into the call lifecycle, and call `await self.hang_up()` from within the call class when you want to terminate: @@ -278,9 +278,9 @@ tear down the transport. ## Making Outbound Calls -Create a \[`Dialog`\][voip.sip.messages.Dialog] subclass, set it as +Create a \[`Dialog`\][voip.sip.dialog.Dialog] subclass, set it as `dialog_class` on your SIP session, and call -\[`dialog.dial`\][voip.sip.messages.Dialog.dial] from +\[`dialog.dial`\][voip.sip.dialog.Dialog.dial] from \[`on_registered`\]\[voip.sip.protocol.SessionInitiationProtocol.on_registered\]: ```python diff --git a/docs/sessions.md b/docs/sessions.md index 8e9cb74..b395a98 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -1,22 +1,18 @@ # Multimedia Sessions / Call Leg Handlers [Session][voip.rtp.Session] is the base class for all call leg handlers. -Each call leg is associated with a [Dialog][voip.sip.messages.Dialog] that +Each call leg is associated with a [Dialog][voip.sip.dialog.Dialog] that carries the SIP dialog state and provides the call lifecycle hooks. ## Dialog -The \[`Dialog`\][voip.sip.messages.Dialog] class manages the SIP dialog state +The \[`Dialog`\][voip.sip.dialog.Dialog] class manages the SIP dialog state and is the primary extension point for application logic. Override -\[`call_received`\][voip.sip.messages.Dialog.call_received] to accept or reject -inbound calls, and \[`hangup_received`\][voip.sip.messages.Dialog.hangup_received] +\[`call_received`\][voip.sip.dialog.Dialog.call_received] to accept or reject +inbound calls, and \[`hangup_received`\][voip.sip.dialog.Dialog.hangup_received] to react when the remote party hangs up. -::: voip.sip.messages.Dialog - -## Base Session - -::: voip.rtp.Session +::: voip.sip.dialog.Dialog ## Audio Handling diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index 9a02099..f7198cf 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -3,6 +3,7 @@ import pytest from voip.sdp.messages import SessionDescription from voip.sip import messages +from voip.sip.dialog import Dialog from voip.sip.types import SipUri @@ -231,7 +232,7 @@ def test_branch__with_branch(self): def test_from_dialog__merges_dialog_headers(self): """Merge the provided headers with the dialog's headers.""" - dialog = messages.Dialog( + dialog = Dialog( uac=SipUri.parse("sips:alice@example.com"), local_tag="local-tag", remote_tag="remote-tag", @@ -290,7 +291,7 @@ def test_from_request__with_dialog_remote_tag(self): b"\r\n" ) request = messages.Message.parse(data) - dialog = messages.Dialog( + dialog = Dialog( uac=SipUri.parse("sip:alice@atlanta.com"), remote_tag="server-tag", ) @@ -313,199 +314,3 @@ def test_from_request__without_dialog(self): request = messages.Message.parse(data) response = messages.Response.from_request(request, status_code=200, phrase="OK") assert response.headers["To"] == request.headers["To"] - - -class TestDialog: - def test_from_header__contains_local_tag(self): - """from_header includes the local_tag parameter.""" - dialog = messages.Dialog( - uac=SipUri.parse("sips:alice@example.com"), - local_tag="my-local-tag", - ) - assert "my-local-tag" in dialog.from_header - - def test_to_header__without_remote_tag(self): - """to_header omits the tag parameter when remote_tag is None.""" - dialog = messages.Dialog( - uac=SipUri.parse("sip:bob@biloxi.com:5060"), - remote_tag=None, - ) - assert ";tag=" not in dialog.to_header - - def test_to_header__with_remote_tag(self): - """to_header includes the remote_tag parameter.""" - dialog = messages.Dialog( - uac=SipUri.parse("sip:bob@biloxi.com:5060"), - remote_tag="their-tag", - ) - assert "their-tag" in dialog.to_header - - def test_headers__returns_required_keys(self): - """Headers property returns From, To, and Call-ID keys.""" - dialog = messages.Dialog(uac=SipUri.parse("sips:alice@example.com")) - headers = dialog.headers - assert "From" in headers - assert "To" in headers - assert "Call-ID" in headers - - def test_from_request__extracts_call_id_and_tags(self): - """from_request creates a Dialog with the correct call_id and tags.""" - data = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" - b"From: sip:alice@atlanta.com;tag=from-tag-99\r\n" - b"To: sip:bob@biloxi.com\r\n" - b"Call-ID: call-99@atlanta.com\r\n" - b"CSeq: 1 INVITE\r\n" - b"\r\n" - ) - request = messages.Message.parse(data) - dialog = messages.Dialog.from_request(request) - assert dialog.call_id == "call-99@atlanta.com" - assert dialog.local_tag == "from-tag-99" - assert dialog.remote_tag is not None - - def test_ringing__delegates_to_invite_tx(self): - """ringing() calls ringing() on the invite_tx when it is set.""" - from unittest.mock import MagicMock - - mock_tx = MagicMock() - dialog = messages.Dialog(invite_tx=mock_tx) - dialog.ringing() - mock_tx.ringing.assert_called_once() - - def test_ringing__noop_when_no_invite_tx(self): - """ringing() is a no-op when invite_tx is None.""" - dialog = messages.Dialog() - dialog.ringing() # must not raise - - def test_accept__delegates_to_invite_tx(self): - """accept() calls answer() on the invite_tx when it is set.""" - from unittest.mock import MagicMock - - class FakeCall: - pass - - mock_tx = MagicMock() - dialog = messages.Dialog(invite_tx=mock_tx) - dialog.accept(call_class=FakeCall) - mock_tx.answer.assert_called_once_with(call_class=FakeCall) - - def test_accept__noop_when_no_invite_tx(self): - """accept() is a no-op when invite_tx is None.""" - dialog = messages.Dialog() - dialog.accept(call_class=object) # must not raise - - def test_reject__delegates_to_invite_tx(self): - """reject() calls reject() on the invite_tx when it is set.""" - from unittest.mock import MagicMock - - from voip.sip.types import SIPStatus - - mock_tx = MagicMock() - dialog = messages.Dialog(invite_tx=mock_tx) - dialog.reject(SIPStatus.NOT_FOUND) - mock_tx.reject.assert_called_once_with(SIPStatus.NOT_FOUND) - - def test_reject__noop_when_no_invite_tx(self): - """reject() is a no-op when invite_tx is None.""" - dialog = messages.Dialog() - dialog.reject() # must not raise - - def test_call_received__rejects_by_default(self): - """call_received() rejects the call with 486 Busy Here by default.""" - from unittest.mock import MagicMock - - mock_tx = MagicMock() - dialog = messages.Dialog(invite_tx=mock_tx) - dialog.call_received() - mock_tx.reject.assert_called_once() - - def test_hangup_received__is_noop(self): - """hangup_received() base implementation does nothing.""" - dialog = messages.Dialog() - dialog.hangup_received() # must not raise - - async def test_bye__noop_when_sip_is_none(self): - """bye() is a no-op when sip is not set.""" - dialog = messages.Dialog() - await dialog.bye() # must not raise - - async def test_bye__sends_bye_request(self): - """bye() sends a BYE request via dialog.sip.""" - from unittest.mock import MagicMock - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "127.0.0.1:5061" - mock_sip.transactions = {} - mock_sip.dialogs = {} - dialog = messages.Dialog( - call_id="test@example.com", - local_party="sip:alice@example.com;tag=a", - remote_party="sip:bob@biloxi.com;tag=b", - remote_contact="sip:bob@192.0.2.2", - outbound_cseq=1, - sip=mock_sip, - ) - import asyncio - - bye_task = asyncio.create_task(dialog.bye()) - await asyncio.sleep(0) - (tx,) = mock_sip.transactions.values() - tx.done.set() - await bye_task - mock_sip.send.assert_called_once() - - async def test_bye__noop_when_local_party_missing(self): - """bye() is a no-op when local_party is not set.""" - from unittest.mock import MagicMock - - mock_sip = MagicMock() - dialog = messages.Dialog( - remote_contact="sip:bob@192.0.2.2", - sip=mock_sip, - ) - await dialog.bye() - mock_sip.send.assert_not_called() - - async def test_bye__noop_when_remote_contact_missing(self): - """bye() is a no-op when remote_contact is not set.""" - from unittest.mock import MagicMock - - mock_sip = MagicMock() - dialog = messages.Dialog( - local_party="sip:alice@example.com;tag=a", - remote_party="sip:bob@biloxi.com;tag=b", - sip=mock_sip, - ) - await dialog.bye() - mock_sip.send.assert_not_called() - - async def test_dial__creates_invite_transaction_and_sends(self): - """dial() creates an InviteTransaction and sends an INVITE.""" - import ipaddress - - from voip.rtp import RealtimeTransportProtocol - from voip.sip.protocol import SessionInitiationProtocol - from voip.sip.types import SipUri - from voip.types import NetworkAddress - - from tests.sip.conftest import CallFixture, FakeTransport - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = SessionInitiationProtocol( - aor=SipUri.parse("sips:alice:secret@example.com"), - rtp=rtp, - dialog_class=messages.Dialog, - ) - sip.transport = transport - sip.local_address = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) - sip.is_secure = True - - dialog = messages.Dialog(sip=sip) - await dialog.dial("sip:bob@biloxi.com", call_class=CallFixture) - assert any(b"INVITE" in data for data in transport.sent) - assert dialog.uac is sip.aor diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py deleted file mode 100644 index 43ae873..0000000 --- a/tests/sip/test_protocol.py +++ /dev/null @@ -1,862 +0,0 @@ -"""Tests for the SIP asyncio protocol handler.""" - -import asyncio -import datetime -import ipaddress - -from voip.sip.messages import Dialog, Message, Response -from voip.sip.protocol import PING, PONG, SessionInitiationProtocol -from voip.sip.transactions import InviteTransaction -from voip.sip.types import SIPMethod, SipUri -from voip.types import NetworkAddress - -from .conftest import INVITE_BYTES, FakeTransport - - -class TestSessionInitiationProtocol: - def test_connection_made__stores_transport(self, fake_transport, rtp): - """Store transport reference after connection_made.""" - session = SessionInitiationProtocol( - aor=SipUri.parse("sips:alice:secret@example.com"), - rtp=rtp, - dialog_class=Dialog, - ) - session.connection_made(fake_transport) - assert session.transport is fake_transport - - def test_connection_made__sets_local_address(self, fake_transport, rtp): - """Set local_address from the socket's sockname after connection_made.""" - session = SessionInitiationProtocol( - aor=SipUri.parse("sips:alice:secret@example.com"), - rtp=rtp, - dialog_class=Dialog, - ) - session.connection_made(fake_transport) - assert str(session.local_address.host) == "127.0.0.1" - assert session.local_address.port == 5061 - - def test_connection_made__sets_is_secure_for_tls(self, rtp): - """Mark connection as secure when ssl_object is present.""" - transport = FakeTransport(_ssl=True) - session = SessionInitiationProtocol( - aor=SipUri.parse("sips:alice:secret@example.com"), - rtp=rtp, - dialog_class=Dialog, - ) - session.connection_made(transport) - assert session.is_secure is True - - def test_connection_made__is_not_secure_without_ssl(self, rtp): - """Mark connection as not secure when ssl_object is absent.""" - transport = FakeTransport(_ssl=False) - session = SessionInitiationProtocol( - aor=SipUri.parse("sip:alice:secret@example.com"), - rtp=rtp, - dialog_class=Dialog, - ) - session.connection_made(transport) - assert session.is_secure is False - - async def test_connection_made__sends_register(self, fake_transport, rtp): - """Send a REGISTER request immediately after connection_made in async context.""" - session = SessionInitiationProtocol( - aor=SipUri.parse("sips:alice:secret@example.com"), - rtp=rtp, - dialog_class=Dialog, - ) - session.connection_made(fake_transport) - if session.keepalive_task: - session.keepalive_task.cancel() - assert any(b"REGISTER" in data for data in fake_transport.sent) - - async def test_connection_made__creates_keepalive_task(self, fake_transport, rtp): - """Create a keepalive task in async context.""" - session = SessionInitiationProtocol( - aor=SipUri.parse("sips:alice:secret@example.com"), - rtp=rtp, - dialog_class=Dialog, - ) - session.connection_made(fake_transport) - assert session.keepalive_task is not None - session.keepalive_task.cancel() - - async def test_send_keepalive__sends_ping(self, sip, fake_transport): - """Send a CRLF CRLF ping after the keepalive interval elapses.""" - sip.keepalive_interval = datetime.timedelta(milliseconds=10) - task = asyncio.create_task(sip.send_keepalive()) - await asyncio.sleep(0.05) - task.cancel() - assert b"\r\n\r\n" in fake_transport.sent - - async def test_send_keepalive__stops_when_transport_is_none(self, sip): - """Stop the keepalive loop immediately when transport is cleared.""" - sip.transport = None - sip.keepalive_interval = datetime.timedelta(milliseconds=1) - await sip.send_keepalive() - - def test_data_received__pong(self, sip): - r"""Receive a PONG (\r\n keepalive reply) without sending any reply.""" - initial_sent = len(sip.transport.sent) - sip.data_received(b"\r\n") - assert len(sip.transport.sent) == initial_sent - - def test_data_received__ping__sends_pong(self, sip, fake_transport): - r"""Reply with \r\n when a PING (\r\n\r\n) is received.""" - sip.data_received(b"\r\n\r\n") - assert b"\r\n" in fake_transport.sent - - def test_data_received__sip_request(self, sip): - """Dispatch a valid SIP request to request_received without error.""" - before = len(sip.transactions) - sip.data_received(INVITE_BYTES) - # An InviteTransaction is added to transactions - assert len(sip.transactions) > before - - def test_data_received__sip_response(self, sip): - """Dispatch a valid SIP response to response_received.""" - branch = list(sip.transactions.keys())[0] - response_bytes = ( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={branch}\r\n" - f"From: sip:alice@example.com;tag=local-tag\r\n" - f"To: sip:example.com;tag=remote-tag\r\n" - f"Call-ID: call-id@example.com\r\n" - f"CSeq: 1 REGISTER\r\n" - f"\r\n" - ).encode() - sip.data_received(response_bytes) - - def test_send__writes_message_bytes(self, sip, fake_transport): - """Serialize and write a SIP message to the transport.""" - response = Response(status_code=200, phrase="OK") - sip.send(response) - assert bytes(response) in fake_transport.sent - - def test_send__with_no_transport(self, sip): - """Skip writing when transport is None.""" - sip.transport = None - response = Response(status_code=200, phrase="OK") - sip.send(response) - - def test_close__closes_transport(self, sip, fake_transport): - """Close the underlying transport.""" - sip.close() - assert fake_transport.closed is True - - def test_close__with_no_transport(self, sip): - """Do nothing when transport is already None.""" - sip.transport = None - sip.close() - - def test_allowed_methods__includes_options(self, sip): - """Always include OPTIONS in allowed methods.""" - assert "OPTIONS" in sip.allowed_methods - - def test_allowed_methods__includes_invite(self, sip): - """Always include INVITE since InviteTransaction defines invite_received.""" - assert SIPMethod.INVITE in sip.allowed_methods - - def test_allow_header__is_comma_separated_string(self, sip): - """allow_header returns a comma-separated string of supported methods.""" - header = sip.allow_header - assert "OPTIONS" in header - assert "," in header - - def test_method_not_allowed__sends_405(self, sip, fake_transport): - """Send a 405 Method Not Allowed response.""" - request = Message.parse( - b"PUBLISH sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKpub\r\n" - b"From: sip:bob@biloxi.com;tag=t1\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: pub-call@biloxi.com\r\n" - b"CSeq: 1 PUBLISH\r\n" - b"\r\n" - ) - sip.method_not_allowed(request) - assert any(b"405" in data for data in fake_transport.sent) - - def test_request_received__options__sends_200(self, sip, fake_transport): - """Reply with 200 OK for an OPTIONS request.""" - request = Message.parse( - b"OPTIONS sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKopt\r\n" - b"From: sip:bob@biloxi.com;tag=t1\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: opt-call@biloxi.com\r\n" - b"CSeq: 2 OPTIONS\r\n" - b"\r\n" - ) - sip.request_received(request) - assert any(b"200" in data for data in fake_transport.sent) - - def test_request_received__invite__creates_transaction(self, sip): - """Create an InviteTransaction for an incoming INVITE.""" - request = Message.parse(INVITE_BYTES) - sip.request_received(request) - assert request.branch in sip.transactions - - def test_request_received__unsupported_method__sends_405(self, sip, fake_transport): - """Send 405 for a method not handled by the transaction class.""" - request = Message.parse( - b"PUBLISH sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKpub2\r\n" - b"From: sip:bob@biloxi.com;tag=t2\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: pub2-call@biloxi.com\r\n" - b"CSeq: 1 PUBLISH\r\n" - b"\r\n" - ) - sip.request_received(request) - assert any(b"405" in data for data in fake_transport.sent) - - def test_request_received__cancel__dispatches_to_existing_transaction( - self, sip, fake_transport - ): - """Dispatch a CANCEL to the matching INVITE transaction.""" - invite = Message.parse(INVITE_BYTES) - sip.request_received(invite) - tx = sip.transactions[invite.branch] - sip.dialogs[(tx.dialog.remote_tag, tx.dialog.local_tag)] = tx.dialog - - cancel = Message.parse( - b"CANCEL sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n" - b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: test-call-id@biloxi.com\r\n" - b"CSeq: 1 CANCEL\r\n" - b"\r\n" - ) - sip.request_received(cancel) - assert any(b"200" in data for data in fake_transport.sent) - - def test_request_received__cancel__gone_when_no_transaction( - self, sip, fake_transport - ): - """Send 410 Gone for a CANCEL with no matching transaction.""" - cancel = Message.parse( - b"CANCEL sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKnone\r\n" - b"From: sip:bob@biloxi.com;tag=t3\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: no-tx@biloxi.com\r\n" - b"CSeq: 1 CANCEL\r\n" - b"\r\n" - ) - sip.request_received(cancel) - assert any(b"410" in data for data in fake_transport.sent) - - async def test_response_received__delegates_to_transaction(self, sip): - """Delegate a response to the matching transaction by branch.""" - branch = list(sip.transactions.keys())[0] - response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={branch}\r\n" - f"From: sip:alice@example.com;tag=local-tag\r\n" - f"To: sip:example.com;tag=remote-tag\r\n" - f"Call-ID: call@example.com\r\n" - f"CSeq: 1 REGISTER\r\n" - f"\r\n".encode() - ) - sip.response_received(response) - - def test_contact__sips_aor_produces_sips_contact(self, sip): - """Build a sips: Contact for a sips: AOR.""" - assert sip.contact.startswith(" before - - def test_dispatch_frame__sip_response(self, rtp, fake_transport): - """Dispatch a SIP response frame to response_received without error.""" - session = self._make_session(rtp, fake_transport) - branch = "z9hG4bKresp-test" - session.transactions[branch] = InviteTransaction( - sip=session, - method=SIPMethod.INVITE, - branch=branch, - cseq=1, - ) - response_bytes = ( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={branch}\r\n" - f"From: sip:alice@example.com;tag=local\r\n" - f"To: sip:example.com;tag=remote\r\n" - f"Call-ID: resp-test@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"\r\n" - ).encode() - session._dispatch_frame(response_bytes) - - # ------------------------------------------------------------------ - # data_received – stream reassembly - # ------------------------------------------------------------------ - - def test_data_received__split_message(self, rtp, fake_transport): - """Reassemble a SIP request delivered in two TCP segments.""" - session = self._make_session(rtp, fake_transport) - split = len(INVITE_BYTES) // 2 - before = len(session.transactions) - session.data_received(INVITE_BYTES[:split]) - assert len(session.transactions) == before # incomplete – not dispatched yet - session.data_received(INVITE_BYTES[split:]) - assert len(session.transactions) > before # now dispatched - - def test_data_received__coalesced_messages(self, rtp, fake_transport): - """Dispatch two SIP requests coalesced into one TCP segment.""" - second = ( - b"OPTIONS sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKopt2\r\n" - b"From: sip:bob@biloxi.com;tag=t88\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: opt-coalesced2@biloxi.com\r\n" - b"CSeq: 2 OPTIONS\r\n" - b"\r\n" - ) - session = self._make_session(rtp, fake_transport) - before = len(session.transactions) - session.data_received(INVITE_BYTES + second) - # INVITE creates a transaction; OPTIONS is answered directly (no tx added) - assert len(session.transactions) > before - - def test_data_received__body_split_across_segments(self, rtp, fake_transport): - """Reassemble a SIP request with a body split across two TCP segments.""" - body = b"v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n" - headers = ( - b"INVITE sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbodysplit\r\n" - b"From: sip:bob@biloxi.com;tag=tbs\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: body-split@biloxi.com\r\n" - b"CSeq: 1 INVITE\r\n" - b"Content-Type: application/sdp\r\n" - b"Content-Length: " + str(len(body)).encode() + b"\r\n" - b"\r\n" - ) - session = self._make_session(rtp, fake_transport) - before = len(session.transactions) - session.data_received(headers + body[:5]) - assert len(session.transactions) == before # body incomplete - session.data_received(body[5:]) - assert len(session.transactions) > before # body complete, dispatched - - # ------------------------------------------------------------------ - # send_keepalive (via _make_session to avoid RegistrationTransaction) - # ------------------------------------------------------------------ - - async def test_send_keepalive__sends_ping_without_sip_fixture(self, rtp): - """send_keepalive writes a PING (CRLF CRLF) after the interval elapses.""" - fake_transport = FakeTransport() - session = self._make_session(rtp, fake_transport) - session.keepalive_interval = datetime.timedelta(milliseconds=10) - task = asyncio.create_task(session.send_keepalive()) - await asyncio.sleep(0.05) - task.cancel() - assert b"\r\n\r\n" in fake_transport.sent - - async def test_send_keepalive__stops_when_transport_cleared(self, rtp): - """send_keepalive exits when transport is set to None.""" - session = self._make_session(rtp) - session.transport = None - session.keepalive_interval = datetime.timedelta(milliseconds=1) - await session.send_keepalive() - - # ------------------------------------------------------------------ - # send / close (via _make_session) - # ------------------------------------------------------------------ - - def test_send__writes_bytes_without_sip_fixture(self, rtp, fake_transport): - """send() serialises and writes a SIP message to the transport.""" - session = self._make_session(rtp, fake_transport) - response = Response(status_code=200, phrase="OK") - session.send(response) - assert bytes(response) in fake_transport.sent - - def test_send__no_op_when_transport_is_none(self, rtp): - """send() is a no-op when transport is None.""" - session = self._make_session(rtp) - session.transport = None - session.send(Response(status_code=200, phrase="OK")) - - def test_close__closes_transport_without_sip_fixture(self, rtp, fake_transport): - """close() closes the underlying transport.""" - session = self._make_session(rtp, fake_transport) - session.close() - assert fake_transport.closed is True - - def test_close__no_op_when_transport_is_none(self, rtp): - """close() is a no-op when transport is None.""" - session = self._make_session(rtp) - session.transport = None - session.close() - - # ------------------------------------------------------------------ - # allowed_methods / allow_header / method_not_allowed (via _make_session) - # ------------------------------------------------------------------ - - def test_allowed_methods__includes_options_without_sip_fixture(self, rtp): - """OPTIONS is always included in allowed_methods.""" - session = self._make_session(rtp) - assert SIPMethod.OPTIONS in session.allowed_methods - - def test_allow_header__is_comma_separated_without_sip_fixture(self, rtp): - """allow_header returns a comma-separated string of methods.""" - session = self._make_session(rtp) - header = session.allow_header - assert "OPTIONS" in header - assert "," in header - - def test_method_not_allowed__sends_405_without_sip_fixture( - self, rtp, fake_transport - ): - """method_not_allowed() sends a 405 Method Not Allowed response.""" - session = self._make_session(rtp, fake_transport) - request = Message.parse( - b"PUBLISH sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKpub3\r\n" - b"From: sip:bob@biloxi.com;tag=t5\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: pub3-call@biloxi.com\r\n" - b"CSeq: 1 PUBLISH\r\n" - b"\r\n" - ) - session.method_not_allowed(request) - assert any(b"405" in data for data in fake_transport.sent) - - # ------------------------------------------------------------------ - # request_received (via _make_session) - # ------------------------------------------------------------------ - - def test_request_received__options_sends_200_without_sip_fixture( - self, rtp, fake_transport - ): - """OPTIONS request is answered with 200 OK.""" - session = self._make_session(rtp, fake_transport) - request = Message.parse( - b"OPTIONS sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKopt3\r\n" - b"From: sip:bob@biloxi.com;tag=t6\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: opt3-call@biloxi.com\r\n" - b"CSeq: 3 OPTIONS\r\n" - b"\r\n" - ) - session.request_received(request) - assert any(b"200" in data for data in fake_transport.sent) - - def test_request_received__invite_creates_transaction_without_sip_fixture( - self, rtp, fake_transport - ): - """INVITE request creates an InviteTransaction.""" - session = self._make_session(rtp, fake_transport) - request = Message.parse(INVITE_BYTES) - before = len(session.transactions) - session.request_received(request) - assert len(session.transactions) > before - - def test_request_received__unsupported_method_sends_405_without_sip_fixture( - self, rtp, fake_transport - ): - """Unsupported method triggers method_not_allowed (405).""" - session = self._make_session(rtp, fake_transport) - request = Message.parse( - b"PUBLISH sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKpub4\r\n" - b"From: sip:bob@biloxi.com;tag=t7\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: pub4-call@biloxi.com\r\n" - b"CSeq: 1 PUBLISH\r\n" - b"\r\n" - ) - session.request_received(request) - assert any(b"405" in data for data in fake_transport.sent) - - def test_request_received__cancel_dispatches_to_transaction_without_sip_fixture( - self, rtp, fake_transport - ): - """CANCEL is forwarded to the matching INVITE transaction.""" - session = self._make_session(rtp, fake_transport) - invite = Message.parse(INVITE_BYTES) - session.request_received(invite) - tx = session.transactions[invite.branch] - session.dialogs[(tx.dialog.remote_tag, tx.dialog.local_tag)] = tx.dialog - cancel = Message.parse( - b"CANCEL sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n" - b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: test-call-id@biloxi.com\r\n" - b"CSeq: 1 CANCEL\r\n" - b"\r\n" - ) - session.request_received(cancel) - assert any(b"200" in data for data in fake_transport.sent) - - def test_request_received__cancel_gone_when_no_transaction_without_sip_fixture( - self, rtp, fake_transport - ): - """CANCEL with no matching transaction returns 410 Gone.""" - session = self._make_session(rtp, fake_transport) - cancel = Message.parse( - b"CANCEL sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKnone2\r\n" - b"From: sip:bob@biloxi.com;tag=t8\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: no-tx2@biloxi.com\r\n" - b"CSeq: 1 CANCEL\r\n" - b"\r\n" - ) - session.request_received(cancel) - assert any(b"410" in data for data in fake_transport.sent) - - # ------------------------------------------------------------------ - # response_received (via _make_session) - # ------------------------------------------------------------------ - - def test_response_received__delegates_to_transaction_without_sip_fixture( - self, rtp, fake_transport - ): - """Response is forwarded to the matching transaction.""" - session = self._make_session(rtp, fake_transport) - branch = "z9hG4bKdel-test" - session.transactions[branch] = InviteTransaction( - sip=session, - method=SIPMethod.INVITE, - branch=branch, - cseq=1, - ) - response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={branch}\r\n" - f"From: sip:alice@example.com;tag=lt\r\n" - f"To: sip:example.com;tag=rt\r\n" - f"Call-ID: del-test@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"\r\n".encode() - ) - session.response_received(response) - - def test_response_received__warns_on_unknown_branch_without_sip_fixture( - self, rtp, fake_transport, caplog - ): - """Log a warning when the response branch is not in transactions.""" - import logging - - session = self._make_session(rtp, fake_transport) - response = Message.parse( - b"SIP/2.0 200 OK\r\n" - b"Via: SIP/2.0/TLS example.com;branch=z9hG4bKunknown\r\n" - b"From: sip:alice@example.com;tag=lt2\r\n" - b"To: sip:example.com;tag=rt2\r\n" - b"Call-ID: unknown-branch@example.com\r\n" - b"CSeq: 1 INVITE\r\n" - b"\r\n" - ) - with caplog.at_level(logging.WARNING): - session.response_received(response) - assert "unknown branch" in caplog.text - - # ------------------------------------------------------------------ - # connection_lost (via _make_session) - # ------------------------------------------------------------------ - - async def test_connection_lost__cancels_keepalive_without_sip_fixture(self, rtp): - """connection_lost() cancels and clears the keepalive task.""" - session = self._make_session(rtp) - session.keepalive_task = asyncio.create_task(asyncio.sleep(9999)) - session.connection_lost(None) - assert session.keepalive_task is None - - def test_connection_lost__clears_transport_without_sip_fixture(self, rtp): - """connection_lost() sets transport to None.""" - session = self._make_session(rtp) - session.connection_lost(None) - assert session.transport is None - - def test_connection_lost__sets_disconnected_event_without_sip_fixture(self, rtp): - """connection_lost() sets the disconnected_event.""" - session = self._make_session(rtp) - session.connection_lost(None) - assert session.disconnected_event.is_set() - - def test_connection_lost__logs_exception_without_sip_fixture(self, rtp, caplog): - """connection_lost() logs an error when an exception is provided.""" - import logging - - session = self._make_session(rtp) - with caplog.at_level(logging.ERROR): - session.connection_lost(OSError("reset")) - assert session.transport is None - - def test_connection_lost__no_keepalive_task_without_sip_fixture(self, rtp): - """connection_lost() is safe when keepalive_task is None.""" - session = self._make_session(rtp) - session.keepalive_task = None - session.connection_lost(None) - assert session.transport is None - - def test_on_registered__is_noop(self, rtp, fake_transport): - """on_registered base implementation does nothing and returns None.""" - session = self._make_session(rtp, fake_transport) - assert session.on_registered() is None diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py deleted file mode 100644 index 3ecf816..0000000 --- a/tests/sip/test_transactions.py +++ /dev/null @@ -1,1186 +0,0 @@ -"""Tests for the SIP transaction layer.""" - -import asyncio -import dataclasses - -import pytest -from voip.rtp import RealtimeTransportProtocol -from voip.sip.exceptions import RegistrationError -from voip.sip.messages import Dialog, Message, Response -from voip.sip.transactions import ( - ByeTransaction, - InviteTransaction, - RegistrationTransaction, -) -from voip.sip.types import DigestAlgorithm, DigestQoP, SIPMethod, SIPStatus, SipUri - -from .conftest import INVITE_BYTES, INVITE_WITH_SDP_BYTES, CallFixture, FakeTransport - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -TEST_PASSWORD = "secret" # noqa: S105 - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def create_sip_session(fake_transport=None, rtp=None): - """Create a minimal SessionInitiationProtocol without async event loop.""" - from voip.sip.messages import Dialog - from voip.sip.protocol import SessionInitiationProtocol - - transport = fake_transport or FakeTransport() - mux = rtp or RealtimeTransportProtocol() - session = SessionInitiationProtocol( - aor=SipUri.parse("sips:alice:secret@example.com"), - rtp=mux, - dialog_class=Dialog, - ) - # Set up local_address without triggering async registration - import ipaddress - - from voip.types import NetworkAddress - - session.transport = transport - session.local_address = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) - session.is_secure = True - return session - - -# --------------------------------------------------------------------------- -# Transaction base class -# --------------------------------------------------------------------------- - - -class TestTransaction: - def test_post_init__valid_branch(self): - """Accept a branch that starts with the magic cookie.""" - sip = create_sip_session() - tx = InviteTransaction( - sip=sip, - method=SIPMethod.INVITE, - branch="z9hG4bK-test-branch", - cseq=1, - ) - assert tx.branch == "z9hG4bK-test-branch" - - def test_post_init__invalid_branch__raises(self): - """Raise ValueError when branch does not start with 'z9hG4bK'.""" - sip = create_sip_session() - with pytest.raises(ValueError): - RegistrationTransaction( - sip=sip, - method=SIPMethod.INVITE, - branch="invalid-branch", - cseq=1, - ) - - def test_headers__contains_via_and_cseq(self): - """Return a dict with Via and CSeq headers.""" - sip = create_sip_session() - tx = InviteTransaction( - sip=sip, - method=SIPMethod.INVITE, - branch="z9hG4bK-headers-test", - cseq=7, - ) - headers = tx.headers - assert "Via" in headers - assert "CSeq" in headers - assert "7 INVITE" in headers["CSeq"] - - def test_response_received__is_noop(self): - """Base response_received does nothing and returns None.""" - sip = create_sip_session() - tx = InviteTransaction( - sip=sip, - method=SIPMethod.INVITE, - branch="z9hG4bK-noop", - cseq=1, - ) - assert tx.response_received(Response(status_code=200, phrase="OK")) is None - - async def test_await__suspends_until_done_is_set(self): - """Awaiting a transaction suspends until done.set() is called.""" - sip = create_sip_session() - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - task = asyncio.create_task(asyncio.wait_for(asyncio.shield(tx), timeout=1.0)) - await asyncio.sleep(0) - assert not task.done() - tx.done.set() - await task - assert task.done() - - def test_send_response__calls_sip_send(self): - """Send a response through the SIP layer.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = InviteTransaction( - sip=sip, - method=SIPMethod.INVITE, - branch="z9hG4bK-send-resp", - cseq=1, - ) - response = Response(status_code=200, phrase="OK") - tx.send_response(response) - assert bytes(response) in transport.sent - - def test_from_request__creates_transaction_from_request(self): - """Create an InviteTransaction from an incoming INVITE request.""" - sip = create_sip_session() - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - assert tx.branch == request.branch - assert tx.method == request.method - assert tx.cseq == request.sequence - - def test_from_request__uses_existing_dialog(self): - """Reuse an existing dialog when one matches the request's tags.""" - sip = create_sip_session() - request = Message.parse(INVITE_BYTES) - # For an INVITE with no To-tag, remote_tag is None and local_tag is from From header - existing_dialog = Dialog( - local_tag=request.local_tag, remote_tag=request.remote_tag - ) - # The lookup key is (request.remote_tag, request.local_tag) - sip.dialogs[(request.remote_tag, request.local_tag)] = existing_dialog - tx = InviteTransaction.from_request(request=request, sip=sip) - assert tx.dialog is existing_dialog - - -# --------------------------------------------------------------------------- -# RegistrationTransaction -# --------------------------------------------------------------------------- - - -class TestRegistrationTransaction: - def test_post_init__sends_register(self): - """Send a REGISTER request immediately on creation.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) - assert any(b"REGISTER" in data for data in transport.sent) - - def test_post_init__includes_contact_header(self): - """Include Contact header in the REGISTER request.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) - register_data = b"".join(transport.sent) - assert b"Contact:" in register_data - - def test_post_init__with_authorization(self): - """Include Authorization header when authorization value is provided.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - RegistrationTransaction( - sip=sip, - method=SIPMethod.REGISTER, - authorization='Digest username="alice"', - ) - register_data = b"".join(transport.sent) - assert b"Authorization:" in register_data - - def test_post_init__with_proxy_authorization(self): - """Include Proxy-Authorization header when proxy_authorization is provided.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - RegistrationTransaction( - sip=sip, - method=SIPMethod.REGISTER, - proxy_authorization='Digest username="alice"', - ) - register_data = b"".join(transport.sent) - assert b"Proxy-Authorization:" in register_data - - def test_response_received__200_ok(self): - """Handle 200 OK without error and remove transaction from registry.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) - sip.transactions[tx.branch] = tx - response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=local-tag\r\n" - f"To: sip:example.com;tag=remote-tag\r\n" - f"Call-ID: reg-call@example.com\r\n" - f"CSeq: 1 REGISTER\r\n" - f"\r\n".encode() - ) - tx.response_received(response) - assert tx.branch not in sip.transactions - - def test_response_received__401_sends_credentials(self): - """Retry with digest credentials after receiving 401 Unauthorized.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) - sip.transactions[tx.branch] = tx - initial_sent_count = len(transport.sent) - - response = Message.parse( - f"SIP/2.0 401 Unauthorized\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=local-tag\r\n" - f"To: sip:example.com;tag=remote-tag\r\n" - f"Call-ID: reg-call@example.com\r\n" - f"CSeq: 1 REGISTER\r\n" - f'WWW-Authenticate: Digest realm="example.com", nonce="abc123", algorithm=SHA-256\r\n' - f"\r\n".encode() - ) - tx.response_received(response) - assert len(transport.sent) > initial_sent_count - second_register = b"".join(transport.sent[initial_sent_count:]) - assert b"Authorization:" in second_register - - def test_response_received__401_with_qop(self): - """Retry with digest credentials and qop=auth after 401 with qop option.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) - sip.transactions[tx.branch] = tx - - response = Message.parse( - f"SIP/2.0 401 Unauthorized\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=lt\r\n" - f"To: sip:example.com;tag=rt\r\n" - f"Call-ID: qop-call@example.com\r\n" - f"CSeq: 1 REGISTER\r\n" - f'WWW-Authenticate: Digest realm="example.com", nonce="nonce1", qop="auth", algorithm=SHA-256\r\n' - f"\r\n".encode() - ) - tx.response_received(response) - second_register = b"".join(transport.sent[1:]) - assert b"qop=auth" in second_register - - def test_response_received__401_with_opaque(self): - """Include opaque parameter in Authorization when challenge includes opaque.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) - sip.transactions[tx.branch] = tx - - response = Message.parse( - f"SIP/2.0 401 Unauthorized\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=lt\r\n" - f"To: sip:example.com;tag=rt\r\n" - f"Call-ID: opaque-call@example.com\r\n" - f"CSeq: 1 REGISTER\r\n" - f'WWW-Authenticate: Digest realm="example.com", nonce="nonce2", opaque="myopaque", algorithm=SHA-256\r\n' - f"\r\n".encode() - ) - tx.response_received(response) - second_register = b"".join(transport.sent[1:]) - assert b"opaque=" in second_register - - def test_response_received__407_sends_proxy_credentials(self): - """Retry with Proxy-Authorization after receiving 407.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) - sip.transactions[tx.branch] = tx - initial_sent_count = len(transport.sent) - - response = Message.parse( - f"SIP/2.0 407 Proxy Authentication Required\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=lt\r\n" - f"To: sip:example.com;tag=rt\r\n" - f"Call-ID: proxy-reg@example.com\r\n" - f"CSeq: 1 REGISTER\r\n" - f'Proxy-Authenticate: Digest realm="example.com", nonce="proxy-nonce", algorithm=SHA-256\r\n' - f"\r\n".encode() - ) - tx.response_received(response) - second_register = b"".join(transport.sent[initial_sent_count:]) - assert b"Proxy-Authorization:" in second_register - - def test_response_received__unknown_status__raises(self): - """Raise NotImplementedError for unrecognised status codes.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) - sip.transactions[tx.branch] = tx - - response = Message.parse( - f"SIP/2.0 500 Server Internal Error\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=lt\r\n" - f"To: sip:example.com;tag=rt\r\n" - f"Call-ID: err-call@example.com\r\n" - f"CSeq: 1 REGISTER\r\n" - f"\r\n".encode() - ) - with pytest.raises(NotImplementedError): - tx.response_received(response) - - def test_parse_auth_challenge__parses_realm_and_nonce(self): - """Extract realm and nonce from a Digest challenge header.""" - header = 'Digest realm="example.com", nonce="abc123"' - params = RegistrationTransaction.parse_auth_challenge(header) - assert params["realm"] == "example.com" - assert params["nonce"] == "abc123" - - def test_parse_auth_challenge__empty_header(self): - """Return empty dict for an empty challenge header.""" - assert RegistrationTransaction.parse_auth_challenge("") == {} - - def test_parse_auth_challenge__multiple_params(self): - """Parse multiple parameters including algorithm and qop.""" - header = 'Digest realm="test.com", nonce="xyz", algorithm=SHA-256, qop="auth"' - params = RegistrationTransaction.parse_auth_challenge(header) - assert params["realm"] == "test.com" - assert params["algorithm"] == "SHA-256" - assert params["qop"] == "auth" - - def test_digest_response__sha256(self): - """Compute a deterministic SHA-256 digest response.""" - result = RegistrationTransaction.digest_response( - username="alice", - password=TEST_PASSWORD, - realm="example.com", - nonce="nonce123", - method="REGISTER", - uri="example.com", - algorithm=DigestAlgorithm.SHA_256, - ) - assert isinstance(result, str) - assert len(result) == 64 - - def test_digest_response__md5(self): - """Compute a deterministic MD5 digest response.""" - result = RegistrationTransaction.digest_response( - username="alice", - password=TEST_PASSWORD, - realm="example.com", - nonce="nonce123", - method="REGISTER", - uri="example.com", - algorithm=DigestAlgorithm.MD5, - ) - assert len(result) == 32 - - def test_digest_response__with_qop_auth(self): - """Include nc and cnonce in the digest when qop=auth.""" - result = RegistrationTransaction.digest_response( - username="alice", - password=TEST_PASSWORD, - realm="example.com", - nonce="nonce123", - method="REGISTER", - uri="example.com", - algorithm=DigestAlgorithm.SHA_256, - qop=DigestQoP.AUTH, - cnonce="clientnonce", - ) - assert isinstance(result, str) - - def test_digest_response__sess_algorithm_requires_cnonce(self): - """Raise ValueError when a -sess algorithm is used without cnonce.""" - with pytest.raises(ValueError, match="cnonce"): - RegistrationTransaction.digest_response( - username="alice", - password=TEST_PASSWORD, - realm="example.com", - nonce="nonce123", - method="REGISTER", - uri="example.com", - algorithm=DigestAlgorithm.SHA_256_SESS, - cnonce=None, - ) - - def test_digest_response__sess_algorithm_with_cnonce(self): - """Compute a digest with a -sess algorithm when cnonce is provided.""" - result = RegistrationTransaction.digest_response( - username="alice", - password=TEST_PASSWORD, - realm="example.com", - nonce="nonce123", - method="REGISTER", - uri="example.com", - algorithm=DigestAlgorithm.SHA_256_SESS, - cnonce="client-cnonce", - ) - assert isinstance(result, str) - - def test_digest_response__unsupported_algorithm_raises(self): - """Raise ValueError for unrecognised digest algorithm.""" - with pytest.raises(ValueError, match="Unsupported"): - RegistrationTransaction.digest_response( - username="alice", - password=TEST_PASSWORD, - realm="example.com", - nonce="nonce123", - method="REGISTER", - uri="example.com", - algorithm="UNKNOWN-ALG", - ) - - def test_response_received__200_ok__calls_on_registered(self): - """Successful registration invokes sip.on_registered().""" - registered_calls: list[bool] = [] - - from voip.sip.protocol import SessionInitiationProtocol - - class TrackingSession(SessionInitiationProtocol): - def on_registered(self) -> None: - registered_calls.append(True) - - import ipaddress - - from voip.rtp import RealtimeTransportProtocol - from voip.types import NetworkAddress - - transport = FakeTransport() - mux = RealtimeTransportProtocol() - from voip.sip.types import SipUri - - session = TrackingSession( - aor=SipUri.parse("sips:alice:secret@example.com"), - rtp=mux, - dialog_class=Dialog, - ) - session.transport = transport - session.local_address = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) - session.is_secure = True - tx = RegistrationTransaction(sip=session, method=SIPMethod.REGISTER) - session.transactions[tx.branch] = tx - response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=lt\r\n" - f"To: sip:example.com;tag=rt\r\n" - f"Call-ID: reg-hook@example.com\r\n" - f"CSeq: 1 REGISTER\r\n" - f"\r\n".encode() - ) - tx.response_received(response) - assert registered_calls == [True] - - -class TestInviteTransaction: - def test_invite_received__delegates_to_dialog(self): - """invite_received sets dialog.invite_tx, dialog.sip and calls dialog.call_received().""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - # Base Dialog.call_received() rejects with 486. - tx.invite_received(request) - assert tx.dialog.invite_tx is tx - assert tx.dialog.sip is sip - assert any(b"486" in data for data in transport.sent) - - def test_ack_received__removes_transaction(self): - """ack_received removes the transaction from sip.transactions.""" - sip = create_sip_session() - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - sip.transactions[tx.branch] = tx - - ack = Message.parse( - b"ACK sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n" - b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: test-call-id@biloxi.com\r\n" - b"CSeq: 1 ACK\r\n" - b"\r\n" - ) - tx.ack_received(ack) - assert tx.branch not in sip.transactions - assert tx.done.is_set() - - def test_bye_received__removes_dialog_and_sends_200(self): - """bye_received removes the dialog, sends 200 OK, and calls hangup_received.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - sip.dialogs[(tx.dialog.remote_tag, tx.dialog.local_tag)] = tx.dialog - - bye = Message.parse( - b"BYE sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbye001\r\n" - b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: test-call-id@biloxi.com\r\n" - b"CSeq: 2 BYE\r\n" - b"\r\n" - ) - tx.bye_received(bye) - assert any(b"200" in data for data in transport.sent) - assert (tx.dialog.remote_tag, tx.dialog.local_tag) not in sip.dialogs - - def test_bye_received__calls_hangup_received(self): - """bye_received calls dialog.hangup_received() after sending 200 OK.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - request = Message.parse(INVITE_BYTES) - - hangup_calls: list[bool] = [] - - @dataclasses.dataclass(kw_only=True) - class TrackingDialog(Dialog): - def hangup_received(self) -> None: - hangup_calls.append(True) - - tx = InviteTransaction.from_request(request=request, sip=sip) - # Replace dialog with TrackingDialog instance that has the same identity fields - tracking_dialog = TrackingDialog( - call_id=tx.dialog.call_id, - local_tag=tx.dialog.local_tag, - remote_tag=tx.dialog.remote_tag, - remote_contact=tx.dialog.remote_contact, - ) - tx.dialog = tracking_dialog - sip.dialogs[(tracking_dialog.remote_tag, tracking_dialog.local_tag)] = ( - tracking_dialog - ) - - bye = Message.parse( - b"BYE sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbye002\r\n" - b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: test-call-id@biloxi.com\r\n" - b"CSeq: 2 BYE\r\n" - b"\r\n" - ) - tx.bye_received(bye) - assert hangup_calls == [True] - - def test_cancel_received__removes_transaction_and_sends_200(self): - """cancel_received removes the transaction, the dialog, and sends 200 OK.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - sip.transactions[tx.branch] = tx - sip.dialogs[(tx.dialog.remote_tag, tx.dialog.local_tag)] = tx.dialog - - cancel = Message.parse( - b"CANCEL sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n" - b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: test-call-id@biloxi.com\r\n" - b"CSeq: 1 CANCEL\r\n" - b"\r\n" - ) - tx.cancel_received(cancel) - assert tx.branch not in sip.transactions - assert any(b"200" in data for data in transport.sent) - - def test_ringing__sends_180(self): - """Ringing sends a 180 Ringing provisional response.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.ringing() - assert any(b"180" in data for data in transport.sent) - - def test_reject__sends_busy_here_by_default(self): - """Reject sends 486 Busy Here when no status code is specified.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.reject() - assert any(b"486" in data for data in transport.sent) - - def test_reject__sends_custom_status_code(self): - """Reject sends the specified status code.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.reject(SIPStatus.NOT_FOUND) - assert any(b"404" in data for data in transport.sent) - - def test_answer__without_sdp__sends_200_ok(self): - """Answer sends 200 OK with SDP even when the INVITE has no body.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.answer(call_class=CallFixture) - assert any(b"200" in data for data in transport.sent) - assert any(b"application/sdp" in data for data in transport.sent) - - def test_answer__with_sdp__negotiates_codec(self): - """Answer negotiates a codec from the SDP offer in the INVITE.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - request = Message.parse(INVITE_WITH_SDP_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.answer(call_class=CallFixture) - assert any(b"200" in data for data in transport.sent) - - def test_answer__stores_dialog(self): - """Answer stores the dialog in sip.dialogs.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.answer(call_class=CallFixture) - assert len(sip.dialogs) > 0 - - def test_answer__dialog_has_local_and_remote_party(self): - """Answer populates dialog.local_party and dialog.remote_party for BYE.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.answer(call_class=CallFixture) - dialog = next(iter(sip.dialogs.values())) - assert dialog.local_party is not None - assert dialog.remote_party is not None - assert "tag=" in dialog.local_party - assert "tag=" in dialog.remote_party - - def test_answer__call_handler_has_dialog(self): - """Answer passes the dialog to the call handler.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - request = Message.parse(INVITE_WITH_SDP_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.answer(call_class=CallFixture) - dialog = next(iter(sip.dialogs.values())) - registered_handler = next(iter(rtp.calls.values())) - assert registered_handler.dialog is dialog - - def test_answer__with_record_route(self): - """Include Record-Route in 200 OK when present in the INVITE.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - request = Message.parse( - b"INVITE sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKrr99\r\n" - b"From: sip:bob@biloxi.com;tag=rr-tag\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: rr-call@biloxi.com\r\n" - b"CSeq: 1 INVITE\r\n" - b"Record-Route: \r\n" - b"\r\n" - ) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.answer(call_class=CallFixture) - ok_data = b"".join(transport.sent) - assert b"Record-Route:" in ok_data - - async def test_make_call__sends_invite(self): - """make_call sends an INVITE request and registers the transaction.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - request = await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - assert any(b"INVITE" in data for data in transport.sent) - assert tx.branch in sip.transactions - assert request.method == SIPMethod.INVITE - - async def test_make_call__sdp_offer_contains_codec(self): - """make_call includes a non-empty SDP offer body in the INVITE.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - sent_data = b"".join(transport.sent) - assert b"application/sdp" in sent_data - assert b"m=audio" in sent_data - - async def test_make_call__with_existing_dialog_reuses_it(self): - """make_call() with a dialog parameter reuses that dialog instance.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - existing_dialog = Dialog() - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call( - "sip:bob@biloxi.com", call_class=CallFixture, dialog=existing_dialog - ) - assert tx.dialog is existing_dialog - assert existing_dialog.sip is sip - - def test_response_received__100_is_noop(self): - """1xx provisional responses are silently ignored.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - sip.transactions[tx.branch] = tx - response = Message.parse( - f"SIP/2.0 100 Trying\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=lt\r\n" - f"To: sip:bob@biloxi.com\r\n" - f"Call-ID: trying-call@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"\r\n".encode() - ) - tx.response_received(response) - assert tx.branch in sip.transactions - - def test_response_received__4xx_removes_transaction(self): - """4xx responses remove the transaction from the registry.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - sip.transactions[tx.branch] = tx - response = Message.parse( - f"SIP/2.0 486 Busy Here\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=lt\r\n" - f"To: sip:bob@biloxi.com;tag=rt\r\n" - f"Call-ID: busy-call@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"\r\n".encode() - ) - tx.response_received(response) - assert tx.branch not in sip.transactions - - async def test_accept_call__sends_ack_on_200_ok(self): - """_accept_call sends an ACK after receiving 200 OK.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - sip.transactions[tx.branch] = tx - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: out-call@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"Contact: \r\n" - f"\r\n".encode() - ) - await tx._accept_call(ok_response) - sent_data = b"".join(transport.sent) - assert b"ACK" in sent_data - - async def test_accept_call__with_sdp_registers_rtp_handler(self): - """_accept_call registers an RTP call handler when remote SDP is present.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: out-sdp@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"Content-Type: application/sdp\r\n" - f"\r\n" - f"v=0\r\n" - f"o=- 1 1 IN IP4 192.0.2.2\r\n" - f"s=-\r\n" - f"c=IN IP4 192.0.2.2\r\n" - f"t=0 0\r\n" - f"m=audio 5004 RTP/AVP 0\r\n" - f"a=rtpmap:0 PCMU/8000\r\n".encode() - ) - await tx._accept_call(ok_response) - assert len(rtp.calls) > 0 - - async def test_accept_call__stores_dialog(self): - """_accept_call stores the dialog in sip.dialogs after 200 OK.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: out-dialog@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"\r\n".encode() - ) - await tx._accept_call(ok_response) - assert len(sip.dialogs) > 0 - - async def test_accept_call__dialog_has_bye_fields(self): - """_accept_call populates dialog.local_party, remote_party, and outbound_cseq.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: bye-fields@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"Contact: \r\n" - f"\r\n".encode() - ) - await tx._accept_call(ok_response) - dialog = next(iter(sip.dialogs.values())) - assert dialog.local_party == "sip:alice@example.com;tag=our-tag" - assert dialog.remote_party == "sip:bob@biloxi.com;tag=callee-tag" - assert dialog.outbound_cseq == 2 - assert dialog.remote_contact == "sip:bob@192.0.2.2" - - async def test_accept_call__call_handler_has_dialog(self): - """_accept_call passes the dialog to the call handler.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: bye-fields@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"Contact: \r\n" - f"Content-Type: application/sdp\r\n" - f"\r\n" - f"v=0\r\n" - f"o=- 1 1 IN IP4 192.0.2.2\r\n" - f"s=-\r\n" - f"c=IN IP4 192.0.2.2\r\n" - f"t=0 0\r\n" - f"m=audio 5004 RTP/AVP 0\r\n" - f"a=rtpmap:0 PCMU/8000\r\n".encode() - ) - await tx._accept_call(ok_response) - dialog = next(iter(sip.dialogs.values())) - registered_handler = next(iter(rtp.calls.values())) - assert registered_handler.dialog is dialog - - async def test_accept_call__no_pending_call_class_sends_ack(self): - """_accept_call sends ACK even when no pending_call_class is set.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - tx.pending_call_class = None - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: no-class@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"\r\n".encode() - ) - await tx._accept_call(ok_response) - sent_data = b"".join(transport.sent) - assert b"ACK" in sent_data - - async def test_accept_call__sdp_no_connection_uses_peer(self): - """_accept_call falls back to transport peer address when SDP has no c= line.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: no-conn@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"Content-Type: application/sdp\r\n" - f"\r\n" - f"v=0\r\n" - f"o=- 1 1 IN IP4 192.0.2.2\r\n" - f"s=-\r\n" - f"t=0 0\r\n" - f"m=audio 5004 RTP/AVP 0\r\n" - f"a=rtpmap:0 PCMU/8000\r\n".encode() - ) - await tx._accept_call(ok_response) - assert len(rtp.calls) > 0 - - async def test_accept_call__sdp_zero_port_no_rtp_address(self): - """_accept_call registers call with None address when audio port is 0.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: zero-port@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"Content-Type: application/sdp\r\n" - f"\r\n" - f"v=0\r\n" - f"o=- 1 1 IN IP4 192.0.2.2\r\n" - f"s=-\r\n" - f"c=IN IP4 192.0.2.2\r\n" - f"t=0 0\r\n" - f"m=audio 0 RTP/AVP 0\r\n".encode() - ) - await tx._accept_call(ok_response) - assert None in rtp.calls - - def test_answer__sdp_without_connection_uses_peer_address(self): - """Use the transport peer address when SDP has no c= connection line.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - # INVITE SDP with audio port > 0 but no c= connection line - request = Message.parse( - b"INVITE sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKnoconn\r\n" - b"From: sip:bob@biloxi.com;tag=noconn-tag\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: noconn-call@biloxi.com\r\n" - b"CSeq: 1 INVITE\r\n" - b"Content-Type: application/sdp\r\n" - b"\r\n" - b"v=0\r\n" - b"o=- 1 1 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"t=0 0\r\n" - b"m=audio 5004 RTP/AVP 0\r\n" - b"a=rtpmap:0 PCMU/8000\r\n" - ) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.answer(call_class=CallFixture) - assert any(b"200" in data for data in transport.sent) - - def test_answer__sdp_with_zero_port_uses_none_rtp_address(self): - """Use None for RTP address when audio port is 0 in SDP offer.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - # INVITE SDP with port=0 (rejected audio) - request = Message.parse( - b"INVITE sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKzeroport\r\n" - b"From: sip:bob@biloxi.com;tag=zero-tag\r\n" - b"To: sip:alice@example.com\r\n" - b"Call-ID: zero-call@biloxi.com\r\n" - b"CSeq: 1 INVITE\r\n" - b"Content-Type: application/sdp\r\n" - b"\r\n" - b"v=0\r\n" - b"o=- 1 1 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 0 RTP/AVP 0\r\n" - ) - tx = InviteTransaction.from_request(request=request, sip=sip) - tx.answer(call_class=CallFixture) - assert any(b"200" in data for data in transport.sent) - - async def test_accept_call__record_route_adds_route_header(self): - """_accept_call includes a Route header in the ACK when Record-Route is present.""" - import ipaddress - - from voip.types import NetworkAddress - - transport = FakeTransport() - rtp = RealtimeTransportProtocol() - rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - sip = create_sip_session(fake_transport=transport, rtp=rtp) - tx = InviteTransaction(sip=sip, method=SIPMethod.INVITE, cseq=1) - await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: rr-call@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"Contact: \r\n" - f"Record-Route: \r\n" - f"\r\n".encode() - ) - await tx._accept_call(ok_response) - sent_data = b"".join(transport.sent) - assert b"ACK" in sent_data - assert b"Route" in sent_data - - -class TestByeTransaction: - def test_bye_transaction__has_default_cseq(self): - """ByeTransaction.cseq defaults to 1.""" - sip = create_sip_session() - tx = ByeTransaction(sip=sip, method=SIPMethod.BYE) - assert tx.cseq == 1 - - def test_response_received__removes_transaction_on_200(self): - """response_received removes the transaction when 200 OK is received.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = ByeTransaction(sip=sip, method=SIPMethod.BYE, cseq=2) - sip.transactions[tx.branch] = tx - response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS 127.0.0.1:5061;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: bye-call@example.com\r\n" - f"CSeq: 2 BYE\r\n" - f"\r\n".encode() - ) - tx.response_received(response) - assert tx.branch not in sip.transactions - assert tx.done.is_set() - - def test_response_received__ignores_provisional_response(self): - """response_received leaves the transaction in place for 1xx responses.""" - transport = FakeTransport() - sip = create_sip_session(fake_transport=transport) - tx = ByeTransaction(sip=sip, method=SIPMethod.BYE, cseq=2) - sip.transactions[tx.branch] = tx - response = Message.parse( - f"SIP/2.0 100 Trying\r\n" - f"Via: SIP/2.0/TLS 127.0.0.1:5061;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com\r\n" - f"Call-ID: bye-call@example.com\r\n" - f"CSeq: 2 BYE\r\n" - f"\r\n".encode() - ) - tx.response_received(response) - assert tx.branch in sip.transactions - assert not tx.done.is_set() - - -class TestRegistrationError: - def test_is_exception(self): - """RegistrationError is a subclass of Exception.""" - assert issubclass(RegistrationError, Exception) - - def test_raise(self): - """RegistrationError can be raised and caught.""" - with pytest.raises(RegistrationError, match="403 Forbidden"): - raise RegistrationError("403 Forbidden") - - def test___str__(self): - """RegistrationError stores the message string.""" - err = RegistrationError("500 Server Error") - assert str(err) == "500 Server Error" diff --git a/tests/test__main.py b/tests/test__main.py deleted file mode 100644 index 06cc8e0..0000000 --- a/tests/test__main.py +++ /dev/null @@ -1,597 +0,0 @@ -"""Tests for the VoIP CLI (__main__ module).""" - -import asyncio -import ipaddress -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -pytest.importorskip("voip.__main__") - -from voip.__main__ import ( - ConsoleMessageProtocol, - _connect_sip_once, - _make_outbound_factory, - _parse_dial_target, - voip, -) -from voip.rtp import RealtimeTransportProtocol -from voip.sip.protocol import SessionInitiationProtocol -from voip.sip.transactions import InviteTransaction -from voip.sip.types import SipUri -from voip.types import NetworkAddress - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_fake_sip_protocol() -> SessionInitiationProtocol: - """Return a minimal SIP protocol stub whose disconnected_event is pre-set.""" - mux = RealtimeTransportProtocol() - aor = SipUri.parse("sips:alice:secret@example.com") - protocol = ConsoleMessageProtocol( - aor=aor, - rtp=mux, - transaction_class=InviteTransaction, - ) - protocol.disconnected_event.set() - protocol.local_address = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) - protocol.is_secure = True - return protocol - - -def _fake_transport_get_extra_info(key, default=None): - """Return standard fake transport metadata for SIP sessions.""" - match key: - case "sockname": - return ("127.0.0.1", 5061) - case "peername": - return ("192.0.2.1", 5061) - case "ssl_object": - return object() - case _: - return default - - -# --------------------------------------------------------------------------- -# _connect_sip_once -# --------------------------------------------------------------------------- - - -class TestConnectSipOnce: - async def test_connects_and_returns_after_disconnect(self): - """_connect_sip_once waits for the session to disconnect, then returns.""" - protocol = _make_fake_sip_protocol() - proxy = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) - - with patch.object( - asyncio.get_event_loop(), - "create_connection", - new=AsyncMock(return_value=(MagicMock(), protocol)), - ): - await _connect_sip_once(lambda: protocol, proxy, False, False) - - async def test_tls_creates_ssl_context(self): - """_connect_sip_once creates an SSL context when use_tls is True.""" - import ssl - - protocol = _make_fake_sip_protocol() - proxy = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) - captured: list = [] - - async def fake_connect(factory, *, host, port, ssl=None): - captured.append(ssl) - return MagicMock(), protocol - - loop = asyncio.get_event_loop() - with patch.object(loop, "create_connection", side_effect=fake_connect): - await _connect_sip_once(lambda: protocol, proxy, True, False) - - assert captured - assert isinstance(captured[0], ssl.SSLContext) - - async def test_no_verify_tls_disables_certificate_check(self): - """_connect_sip_once disables cert verification when no_verify_tls is True.""" - import ssl - - protocol = _make_fake_sip_protocol() - proxy = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) - captured: list = [] - - async def fake_connect(factory, *, host, port, ssl=None): - captured.append(ssl) - return MagicMock(), protocol - - loop = asyncio.get_event_loop() - with patch.object(loop, "create_connection", side_effect=fake_connect): - await _connect_sip_once(lambda: protocol, proxy, True, True) - - assert captured - ctx = captured[0] - assert isinstance(ctx, ssl.SSLContext) - assert ctx.check_hostname is False - assert ctx.verify_mode == ssl.CERT_NONE - - -# --------------------------------------------------------------------------- -# ConsoleMessageProtocol -# --------------------------------------------------------------------------- - - -class TestConsoleMessageProtocol: - def test_verbose_0_does_not_print(self, capsys): - """Verbose=0 suppresses all output from pprint.""" - from voip.rtp import RealtimeTransportProtocol - from voip.sip.messages import Request - - mux = RealtimeTransportProtocol() - aor = SipUri.parse("sips:alice:secret@example.com") - proto = ConsoleMessageProtocol( - aor=aor, - rtp=mux, - transaction_class=InviteTransaction, - verbose=0, - ) - request = Request( - method="OPTIONS", - uri="sip:alice@example.com", - headers={ - "Via": "SIP/2.0/TLS 127.0.0.1:5061;branch=z9hG4bKtest", - "From": "sip:alice@example.com;tag=t1", - "To": "sip:alice@example.com", - "Call-ID": "c@test", - "CSeq": "1 OPTIONS", - }, - ) - proto.pprint(request) - captured = capsys.readouterr() - assert captured.out == "" - - def test_verbose_3_prints_message(self, capsys): - """Verbose=3 prints the formatted SIP message with peer address.""" - import dataclasses - - from voip.rtp import RealtimeTransportProtocol - from voip.sip.messages import Request - - mux = RealtimeTransportProtocol() - aor = SipUri.parse("sips:alice:secret@example.com") - proto = ConsoleMessageProtocol( - aor=aor, - rtp=mux, - transaction_class=InviteTransaction, - verbose=3, - ) - - @dataclasses.dataclass - class FakeTransportStub: - def get_extra_info(self, key, default=None): - match key: - case "peername": - return ("192.0.2.1", 5061) - case _: - return default - - proto.transport = FakeTransportStub() - request = Request( - method="OPTIONS", - uri="sip:alice@example.com", - headers={ - "Via": "SIP/2.0/TLS 127.0.0.1:5061;branch=z9hG4bKtest", - "From": "sip:alice@example.com;tag=t1", - "To": "sip:alice@example.com", - "Call-ID": "c2@test", - "CSeq": "1 OPTIONS", - }, - ) - proto.pprint(request) - captured = capsys.readouterr() - assert "192.0.2.1" in captured.out - - def test_verbose_3_prints_message_ipv6(self, capsys): - """Verbose=3 formats IPv6 peer address in brackets.""" - import dataclasses - - from voip.rtp import RealtimeTransportProtocol - from voip.sip.messages import Request - - mux = RealtimeTransportProtocol() - aor = SipUri.parse("sips:alice:secret@example.com") - proto = ConsoleMessageProtocol( - aor=aor, - rtp=mux, - transaction_class=InviteTransaction, - verbose=3, - ) - - @dataclasses.dataclass - class FakeIPv6Transport: - def get_extra_info(self, key, default=None): - match key: - case "peername": - return ("::1", 5061) - case _: - return default - - proto.transport = FakeIPv6Transport() - request = Request( - method="OPTIONS", - uri="sip:alice@example.com", - headers={ - "Via": "SIP/2.0/TLS ::1;branch=z9hG4bKtest6", - "From": "sip:alice@example.com;tag=t2", - "To": "sip:alice@example.com", - "Call-ID": "c3@test", - "CSeq": "1 OPTIONS", - }, - ) - proto.pprint(request) - captured = capsys.readouterr() - assert "[::1]" in captured.out - - def test_verbose_3_no_transport_prints_unknown(self, capsys): - """Verbose=3 prints '[unknown]' when no transport is set.""" - from voip.rtp import RealtimeTransportProtocol - from voip.sip.messages import Request - - mux = RealtimeTransportProtocol() - aor = SipUri.parse("sips:alice:secret@example.com") - proto = ConsoleMessageProtocol( - aor=aor, - rtp=mux, - transaction_class=InviteTransaction, - verbose=3, - ) - proto.transport = None - request = Request( - method="OPTIONS", - uri="sip:alice@example.com", - headers={ - "Via": "SIP/2.0/TLS 127.0.0.1:5061;branch=z9hG4bKtest7", - "From": "sip:alice@example.com;tag=t3", - "To": "sip:alice@example.com", - "Call-ID": "c4@test", - "CSeq": "1 OPTIONS", - }, - ) - proto.pprint(request) - captured = capsys.readouterr() - assert "[unknown]" in captured.out - - -# --------------------------------------------------------------------------- -# _parse_dial_target -# --------------------------------------------------------------------------- - - -class TestParseDialTarget: - def test_none_returns_none(self): - """_parse_dial_target returns None when no --dial option is provided.""" - assert _parse_dial_target(None) is None - - def test_valid_uri_returns_sip_uri(self): - """_parse_dial_target returns a parsed SipUri for a valid SIP URI string.""" - result = _parse_dial_target("sip:bob@biloxi.com") - assert isinstance(result, SipUri) - assert str(result.user) == "bob" - assert str(result.host) == "biloxi.com" - - def test_invalid_uri_raises_bad_parameter(self): - """_parse_dial_target raises click.BadParameter for an invalid SIP URI.""" - import click # noqa: PLC0415 - - with pytest.raises(click.BadParameter): - _parse_dial_target("not-a-sip-uri") - - -# --------------------------------------------------------------------------- -# _make_outbound_factory -# --------------------------------------------------------------------------- - - -class TestMakeOutboundFactory: - def test_factory_creates_protocol_with_dial_target(self): - """Factory produces a protocol whose dial_target matches the target URI.""" - mux = RealtimeTransportProtocol() - aor = SipUri.parse("sips:alice:secret@example.com") - target = SipUri.parse("sip:bob@biloxi.com") - - from voip.audio import EchoCall # noqa: PLC0415 - - factory = _make_outbound_factory( - verbose=0, - aor=aor, - rtp_protocol=mux, - target_uri=target, - call_class=EchoCall, - call_kwargs={}, - ) - proto = factory() - assert proto.dial_target == str(target) - - -# --------------------------------------------------------------------------- -# echo --dial command -# --------------------------------------------------------------------------- - - -class TestEchoDialCommand: - def test_echo_dial__invalid_target_raises_bad_parameter(self): - """Echo --dial raises BadParameter for an invalid SIP URI target.""" - from click.testing import CliRunner # noqa: PLC0415 - - runner = CliRunner() - result = runner.invoke( - voip, - ["sip", "sips:alice:secret@example.com", "echo", "--dial", "not-a-sip-uri"], - ) - assert result.exit_code != 0 - assert "--dial" in result.output - - def test_echo_dial__initiates_outbound_invite(self): - """Echo --dial registers, then sends an INVITE to the target.""" - import dataclasses # noqa: PLC0415 - - sent_data: list[bytes] = [] - - @dataclasses.dataclass - class WritingTransport: - closed: bool = False - - def write(self, data: bytes) -> None: - sent_data.append(data) - - def close(self) -> None: - self.closed = True - - def get_extra_info(self, key, default=None): - return _fake_transport_get_extra_info(key, default) - - transport = WritingTransport() - mux = RealtimeTransportProtocol() - mux.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - - async def fake_connect_rtp(proxy_addr, stun): - return MagicMock(), mux - - async def fake_connect_sip_once(factory, proxy_addr, use_tls, no_verify_tls): - proto = factory() - proto.connection_made(transport) - if proto.keepalive_task: - proto.keepalive_task.cancel() - proto.keepalive_task = None - await _simulate_register_ok(proto, "reg@example.com") - await asyncio.sleep(0) - - _run_dial_command( - fake_connect_rtp, - fake_connect_sip_once, - [ - "sip", - "sips:alice:secret@example.com", - "echo", - "--dial", - "sip:bob@biloxi.com", - ], - ) - - assert any(b"INVITE" in data for data in sent_data) - - def test_echo_dial__bye_received_closes_session(self): - """Echo --dial closes the session when BYE arrives.""" - import dataclasses # noqa: PLC0415 - - proto_ref: list = [] - - @dataclasses.dataclass - class ClosingTransport: - closed: bool = False - sent: list = dataclasses.field(default_factory=list) - - def write(self, data: bytes) -> None: - self.sent.append(data) - - def close(self) -> None: - self.closed = True - if proto_ref: - proto_ref[0].connection_lost(None) - - def get_extra_info(self, key, default=None): - return _fake_transport_get_extra_info(key, default) - - transport = ClosingTransport() - mux = RealtimeTransportProtocol() - mux.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - - async def fake_connect_rtp(proxy_addr, stun): - return MagicMock(), mux - - async def fake_connect_sip_once(factory, proxy_addr, use_tls, no_verify_tls): - proto = factory() - proto_ref.append(proto) - proto.connection_made(transport) - if proto.keepalive_task: - proto.keepalive_task.cancel() - proto.keepalive_task = None - await _simulate_register_ok(proto, "reg2@example.com") - await asyncio.sleep(0) - invite_tx = _find_invite_tx(proto) - if invite_tx is None: - return - await _simulate_invite_ok(invite_tx) - await _simulate_bye(proto) - await proto.disconnected_event.wait() - - _run_dial_command( - fake_connect_rtp, - fake_connect_sip_once, - [ - "sip", - "sips:alice:secret@example.com", - "echo", - "--dial", - "sip:bob@biloxi.com", - ], - ) - - assert transport.closed - - -# --------------------------------------------------------------------------- -# say command -# --------------------------------------------------------------------------- - - -class TestSayCommand: - def test_say__invalid_target_raises_bad_parameter(self): - """Say raises BadParameter for an invalid SIP URI target.""" - from click.testing import CliRunner # noqa: PLC0415 - - runner = CliRunner() - result = runner.invoke( - voip, - [ - "sip", - "sips:alice:secret@example.com", - "say", - "not-a-sip-uri", - "Hello", - ], - ) - assert result.exit_code != 0 - assert "TARGET" in result.output - - def test_say__initiates_outbound_invite(self): - """Say registers and sends an INVITE to the target.""" - import dataclasses # noqa: PLC0415 - - sent_data: list[bytes] = [] - - @dataclasses.dataclass - class WritingTransport: - closed: bool = False - - def write(self, data: bytes) -> None: - sent_data.append(data) - - def close(self) -> None: - self.closed = True - - def get_extra_info(self, key, default=None): - return _fake_transport_get_extra_info(key, default) - - transport = WritingTransport() - mux = RealtimeTransportProtocol() - mux.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) - - async def fake_connect_rtp(proxy_addr, stun): - return MagicMock(), mux - - async def fake_connect_sip_once(factory, proxy_addr, use_tls, no_verify_tls): - proto = factory() - proto.connection_made(transport) - if proto.keepalive_task: - proto.keepalive_task.cancel() - proto.keepalive_task = None - await _simulate_register_ok(proto, "reg-say@example.com") - await asyncio.sleep(0) - - _run_dial_command( - fake_connect_rtp, - fake_connect_sip_once, - [ - "sip", - "sips:alice:secret@example.com", - "say", - "sip:bob@biloxi.com", - "Hello!", - ], - ) - - assert any(b"INVITE" in data for data in sent_data) - - -# --------------------------------------------------------------------------- -# Helpers used by TestEchoDialCommand / TestSayCommand -# --------------------------------------------------------------------------- - - -async def _simulate_register_ok(proto, call_id: str) -> None: - """Send a 200 OK REGISTER response to *proto*.""" - from voip.sip.messages import Message - - reg_branch = list(proto.transactions.keys())[0] - proto.response_received( - Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={reg_branch}\r\n" - f"From: sips:alice@example.com;tag=our-tag\r\n" - f"To: sips:example.com;tag=rt\r\n" - f"Call-ID: {call_id}\r\n" - f"CSeq: 1 REGISTER\r\n" - f"\r\n".encode() - ) - ) - - -def _find_invite_tx(proto): - """Return the first outbound InviteTransaction in *proto.transactions*.""" - return next( - (tx for tx in proto.transactions.values() if hasattr(tx, "pending_call_class")), - None, - ) - - -async def _simulate_invite_ok(invite_tx) -> None: - """Send a 200 OK INVITE response and complete _accept_call.""" - from voip.sip.messages import Message - - ok_invite = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS example.com;branch={invite_tx.branch}\r\n" - f"From: sips:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: invite2@example.com\r\n" - f"CSeq: 1 INVITE\r\n" - f"\r\n".encode() - ) - await invite_tx._accept_call(ok_invite) - - -async def _simulate_bye(proto) -> None: - """Deliver a BYE request to *proto*.""" - from voip.sip.messages import Message - - proto.request_received( - Message.parse( - b"BYE sip:alice@example.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbye999\r\n" - b"From: sip:bob@biloxi.com;tag=callee-tag\r\n" - b"To: sips:alice@example.com;tag=our-tag\r\n" - b"Call-ID: invite2@example.com\r\n" - b"CSeq: 2 BYE\r\n" - b"\r\n" - ) - ) - - -def _run_dial_command( - fake_connect_rtp, - fake_connect_sip_once, - cli_args: list[str], -) -> None: - """Invoke a dial-capable CLI command with patched transport helpers.""" - import voip.__main__ as main_module # noqa: PLC0415 - from click.testing import CliRunner # noqa: PLC0415 - - orig_rtp = main_module._connect_rtp - orig_sip_once = main_module._connect_sip_once - main_module._connect_rtp = fake_connect_rtp - main_module._connect_sip_once = fake_connect_sip_once - try: - result = CliRunner().invoke(voip, cli_args, catch_exceptions=False) - finally: - main_module._connect_rtp = orig_rtp - main_module._connect_sip_once = orig_sip_once - assert result.exit_code == 0 diff --git a/tests/test_ai.py b/tests/test_ai.py deleted file mode 100644 index 749c286..0000000 --- a/tests/test_ai.py +++ /dev/null @@ -1,702 +0,0 @@ -"""Tests for AI-powered call handlers (TranscribeCall and AgentCall).""" - -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -np = pytest.importorskip("numpy") -pytest.importorskip("faster_whisper") -pytest.importorskip("ollama") -pytest.importorskip("pocket_tts") - -from voip.ai import AgentCall, SayCall, TranscribeCall # noqa: E402 -from voip.audio import AudioCall # noqa: E402 -from voip.codecs.pcma import PCMA # noqa: E402 -from voip.codecs.pcmu import PCMU # noqa: E402 -from voip.rtp import RTPPayloadType # noqa: E402 -from voip.sdp.types import MediaDescription, RTPPayloadFormat # noqa: E402 -from voip.sip.types import CallerID # noqa: E402 - - -def _make_media(fmt: str, rtpmap: str | None = None) -> MediaDescription: - """Build a single-codec MediaDescription for use in tests.""" - if rtpmap: - payload_format = RTPPayloadFormat.parse(rtpmap) - else: - payload_format = RTPPayloadFormat(payload_type=int(fmt)) - return MediaDescription( - media="audio", port=0, proto="RTP/AVP", fmt=[payload_format] - ) - - -OPUS_MEDIA = _make_media("111", "111 opus/48000/2") -PCMA_MEDIA = _make_media("8", "8 PCMA/8000") -PCMU_MEDIA = _make_media("0") # static PT, no rtpmap -G722_MEDIA = _make_media("9", "9 G722/8000") - - -def make_whisper_call( - model_mock: MagicMock, call_class=None, media: MediaDescription | None = None -) -> TranscribeCall: - """Return a TranscribeCall with a mocked Whisper model.""" - cls = call_class or TranscribeCall - med = media if media is not None else OPUS_MEDIA - with patch("voip.ai.WhisperModel", return_value=model_mock): - return cls( - rtp=MagicMock(), - sip=MagicMock(), - caller=CallerID("sip:bob@biloxi.com"), - media=med, - ) - - -def make_agent_call( - model_mock: MagicMock, - tts_mock: MagicMock, - call_class=None, - media: MediaDescription | None = None, - **kwargs, -) -> AgentCall: - """Return an AgentCall with mocked Whisper model and Pocket TTS model.""" - cls = call_class or AgentCall - med = media if media is not None else OPUS_MEDIA - with ( - patch("voip.ai.WhisperModel", return_value=model_mock), - patch("voip.ai.TTSModel") as tts_cls, - ): - tts_cls.load_model.return_value = tts_mock - return cls( - rtp=MagicMock(), - sip=MagicMock(), - caller=CallerID("sip:bob@biloxi.com"), - media=med, - **kwargs, - ) - - -class TestTranscribeCall: - def test_whisper_call__is_audio_call(self): - """TranscribeCall is a subclass of AudioCall.""" - assert issubclass(TranscribeCall, AudioCall) - - def test_init__uses_pre_loaded_model_instance(self): - """When stt_model is a WhisperModel instance it is stored directly (no re-load).""" - model_instance = MagicMock() - with patch("voip.ai.WhisperModel") as wm_cls: - # Pass the instance directly — the constructor must NOT be called again. - call = TranscribeCall( - rtp=MagicMock(), - sip=MagicMock(), - media=OPUS_MEDIA, - stt_model=model_instance, - caller=CallerID(""), - ) - wm_cls.assert_not_called() - assert call.stt_model is model_instance - - def test_init__stores_media(self): - """Media is stored and accessible as self.media.""" - call = make_whisper_call(MagicMock()) - assert call.media is OPUS_MEDIA - - def test_init__derives_payload_type_from_opus_media(self): - """payload_type is 111 (Opus) when given OPUS_MEDIA.""" - call = make_whisper_call(MagicMock()) - assert call.payload_type == RTPPayloadType.OPUS - - def test_init__derives_payload_type_from_pcma_media(self): - """payload_type is 8 (PCMA) when given PCMA_MEDIA.""" - call = make_whisper_call(MagicMock(), media=PCMA_MEDIA) - assert call.payload_type == RTPPayloadType.PCMA - - def test_audio_received__initializes_vad_state(self): - """TranscribeCall starts with an empty speech buffer and no flush timer.""" - call = make_whisper_call(MagicMock()) - assert call._speech_buffer.size == 0 - assert call._flush_voice_buffer_handle is None - - def test_audio_received__silence_audio_accumulates_in_buffer(self): - """Silence audio (below voice_rms_threshold) is still buffered.""" - call = make_whisper_call(MagicMock()) - with patch("voip.audio.asyncio.get_event_loop"): - call.audio_received(audio=np.zeros(320, dtype=np.float32), rms=0.0) - assert call._speech_buffer.size == 320 - - def test_audio_received__speech_audio_accumulates_in_buffer(self): - """Audio above voice_rms_threshold is added to _speech_buffer.""" - call = make_whisper_call(MagicMock()) - speech = np.ones(320, dtype=np.float32) * 0.6 - call.audio_received(audio=speech, rms=0.6) - assert call._speech_buffer.size == 320 - - def test_audio_received__silence_arms_flush_timer(self): - """Silence arms the flush debounce timer.""" - call = make_whisper_call(MagicMock()) - with patch("voip.audio.asyncio.get_event_loop") as mock_loop: - handle = MagicMock() - mock_loop.return_value.call_later.return_value = handle - call.audio_received(audio=np.zeros(320, dtype=np.float32), rms=0.0) - mock_loop.return_value.call_later.assert_called_once_with( - call.silence_gap.total_seconds(), - call.flush_voice_buffer, - ) - assert call._flush_voice_buffer_handle is handle - - def test_audio_received__silence_does_not_rearm_when_timer_running(self): - """A second silence packet does not create a second timer.""" - call = make_whisper_call(MagicMock()) - call._flush_voice_buffer_handle = MagicMock() - with patch("voip.audio.asyncio.get_event_loop") as mock_loop: - call.audio_received(audio=np.zeros(320, dtype=np.float32), rms=0.0) - mock_loop.return_value.call_later.assert_not_called() - - def test_audio_received__speech_cancels_pending_timer(self): - """Speech audio cancels any running flush timer.""" - call = make_whisper_call(MagicMock()) - handle = MagicMock() - call._flush_voice_buffer_handle = handle - call.audio_received(audio=np.ones(320, dtype=np.float32) * 0.6, rms=0.6) - handle.cancel.assert_called_once() - assert call._flush_voice_buffer_handle is None - - def test_audio_received__empty_array_accumulates_in_buffer(self): - """Zero-length audio arrays are accepted into the speech buffer.""" - call = make_whisper_call(MagicMock()) - with patch("voip.audio.asyncio.get_event_loop"): - call.audio_received(audio=np.zeros(0, dtype=np.float32), rms=0.0) - assert call._speech_buffer.size == 0 - - async def test_flush_speech_buffer__transcribes_accumulated_audio(self): - """flush_voice_buffer concatenates speech and schedules transcription.""" - transcriptions = [] - model_mock = MagicMock() - seg = MagicMock() - seg.text = "hello" - model_mock.transcribe.return_value = ([seg], MagicMock()) - - class Capture(TranscribeCall): - def transcription_received(self, text: str) -> None: - transcriptions.append(text) - - call = make_whisper_call(model_mock, Capture) - # Fill buffer with 2 s of audio to pass the length and RMS thresholds. - call._speech_buffer = np.ones(call.sampling_rate_hz * 2, dtype=np.float32) - call.flush_voice_buffer() - await asyncio.sleep(0.1) - assert transcriptions == ["hello"] - assert call._speech_buffer.size == 0 - - def test_flush_speech_buffer__no_op_when_buffer_empty(self): - """flush_voice_buffer does nothing when the speech buffer is empty.""" - call = make_whisper_call(MagicMock()) - with patch("voip.audio.asyncio.create_task") as mock_ct: - call.flush_voice_buffer() - mock_ct.assert_not_called() - - def test_flush_speech_buffer__resets_state(self): - """flush_voice_buffer clears _flush_voice_buffer_handle and the speech buffer.""" - call = make_whisper_call(MagicMock()) - call._flush_voice_buffer_handle = MagicMock() - call._speech_buffer = np.ones(call.sampling_rate_hz * 2, dtype=np.float32) - with patch("voip.audio.asyncio.create_task", side_effect=lambda c: c.close()): - call.flush_voice_buffer() - assert call._flush_voice_buffer_handle is None - - async def test_speech_buffer_ready__skips_short_audio(self): - """flush_voice_buffer discards audio shorter than silence_gap.""" - model_mock = MagicMock() - call = make_whisper_call(model_mock) - # Pre-fill the buffer with fewer samples than sampling_rate_hz * silence_gap_secs. - call._speech_buffer = np.ones(100, dtype=np.float32) - with patch("voip.audio.asyncio.create_task") as mock_ct: - call.flush_voice_buffer() - mock_ct.assert_not_called() - model_mock.transcribe.assert_not_called() - - async def test_transcribe__strips_whitespace(self): - """Strip leading and trailing whitespace from the transcription text.""" - transcriptions = [] - model_mock = MagicMock() - seg = MagicMock() - seg.text = " hello world " - model_mock.transcribe.return_value = ([seg], MagicMock()) - - class Capture(TranscribeCall): - def transcription_received(self, text: str) -> None: - transcriptions.append(text) - - call = make_whisper_call(model_mock, Capture) - await call.transcribe(np.zeros(16000, dtype=np.float32)) - assert transcriptions == ["hello world"] - - def test_run_transcription__passes_numpy_array_directly(self): - """Pass a numpy float32 array to the Whisper model without file I/O.""" - model_mock = MagicMock() - seg = MagicMock() - seg.text = "test" - model_mock.transcribe.return_value = ([seg], MagicMock()) - call = make_whisper_call(model_mock) - audio = np.zeros(16000, dtype=np.float32) - assert call.run_transcription(audio) == "test" - model_mock.transcribe.assert_called_once_with(audio) - - def test_run_transcription__no_file_written(self): - """The transcription path must not write any files to disk.""" - model_mock = MagicMock() - seg = MagicMock() - seg.text = "" - model_mock.transcribe.return_value = ([seg], MagicMock()) - call = make_whisper_call(model_mock) - with patch( - "builtins.open", side_effect=AssertionError("open() must not be called") - ): - call.run_transcription(np.zeros(16000, dtype=np.float32)) - - def test_decode_payload__opus__delegates_to_opus_codec(self): - """decode_payload delegates to Opus.decode for Opus media.""" - from voip.codecs.opus import Opus # noqa: PLC0415 - - call = make_whisper_call(MagicMock(), media=OPUS_MEDIA) - assert call.payload_type == RTPPayloadType.OPUS - with patch.object( - Opus, "decode", return_value=np.zeros(16000, dtype=np.float32) - ) as mock_decode: - call.decode_payload(b"pkt") - mock_decode.assert_called_once_with( - b"pkt", call.sampling_rate_hz, input_rate_hz=call.sample_rate - ) - - def test_decode_payload__pcma__delegates_to_pcma_codec(self): - """decode_payload delegates to PCMA.decode for PCMA media.""" - call = make_whisper_call(MagicMock(), media=PCMA_MEDIA) - assert call.payload_type == RTPPayloadType.PCMA - with patch.object( - PCMA, "decode", return_value=np.zeros(16000, dtype=np.float32) - ) as mock_decode: - call.decode_payload(b"pkt") - mock_decode.assert_called_once_with( - b"pkt", call.sampling_rate_hz, input_rate_hz=call.sample_rate - ) - - def test_decode_payload__pcmu__delegates_to_pcmu_codec(self): - """decode_payload delegates to PCMU.decode for PCMU media.""" - call = make_whisper_call(MagicMock(), media=PCMU_MEDIA) - assert call.payload_type == RTPPayloadType.PCMU - with patch.object( - PCMU, "decode", return_value=np.zeros(16000, dtype=np.float32) - ) as mock_decode: - call.decode_payload(b"pkt") - mock_decode.assert_called_once_with( - b"pkt", call.sampling_rate_hz, input_rate_hz=call.sample_rate - ) - - def test_decode_payload__passes_sdp_sample_rate_as_input(self): - """decode_payload passes the SDP-negotiated sample rate as input_rate_hz.""" - wideband_pcma = _make_media("8", "8 PCMA/16000") - call = make_whisper_call(MagicMock(), media=wideband_pcma) - assert call.sample_rate == 16000 - with patch.object( - PCMA, "decode", return_value=np.zeros(16000, dtype=np.float32) - ) as mock_decode: - call.decode_payload(b"pkt") - mock_decode.assert_called_once_with( - b"pkt", call.sampling_rate_hz, input_rate_hz=16000 - ) - - async def test_transcribe__raises_on_general_error(self): - """Exceptions from transcription propagate to the caller.""" - call = make_whisper_call(MagicMock()) - with ( - patch.object( - call, "run_transcription", side_effect=RuntimeError("model error") - ), - pytest.raises(RuntimeError, match="model error"), - ): - await call.transcribe(np.zeros(16000, dtype=np.float32)) - - async def test_transcribe__cancelled_error_is_re_raised(self): - """_transcribe re-raises CancelledError without logging it as an exception.""" - model_mock = MagicMock() - call = make_whisper_call(model_mock) - with ( - patch.object(call, "run_transcription", side_effect=asyncio.CancelledError), - pytest.raises(asyncio.CancelledError), - ): - await call.transcribe(np.zeros(16000, dtype=np.float32)) - - async def test_transcribe__empty_transcription_not_delivered(self): - """Whitespace-only transcription is silently discarded.""" - transcriptions = [] - model_mock = MagicMock() - seg = MagicMock() - seg.text = " " - model_mock.transcribe.return_value = ([seg], MagicMock()) - - class Capture(TranscribeCall): - def transcription_received(self, text: str) -> None: - transcriptions.append(text) - - call = make_whisper_call(model_mock, Capture) - await call.transcribe(np.zeros(16000, dtype=np.float32)) - assert transcriptions == [] - - -class TestAgentCall: - def test_agent_call__is_whisper_call(self): - """AgentCall is a subclass of TranscribeCall.""" - assert issubclass(AgentCall, TranscribeCall) - - @pytest.mark.asyncio - async def test_init__loads_tts_model_when_none(self): - """Load the default Pocket TTS model when tts_model is None.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - with ( - patch("voip.ai.WhisperModel", return_value=MagicMock()), - patch("voip.ai.TTSModel") as tts_cls, - ): - tts_cls.load_model.return_value = tts_mock - call = AgentCall( - rtp=MagicMock(), sip=MagicMock(), media=OPUS_MEDIA, caller=CallerID("") - ) - tts_cls.load_model.assert_called_once() - assert call.tts_model is tts_mock - - @pytest.mark.asyncio - async def test_init__uses_provided_tts_model(self): - """Use the provided TTSModel instance instead of loading a new one.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - with ( - patch("voip.ai.WhisperModel", return_value=MagicMock()), - patch("voip.ai.TTSModel") as tts_cls, - ): - call = AgentCall( - rtp=MagicMock(), - sip=MagicMock(), - media=OPUS_MEDIA, - tts_model=tts_mock, - caller=CallerID(""), - ) - tts_cls.load_model.assert_not_called() - assert call.tts_model is tts_mock - - @pytest.mark.asyncio - async def test_init__loads_voice_state(self): - """Get the voice state from the TTS model on init.""" - tts_mock = MagicMock() - voice_state = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = voice_state - with ( - patch("voip.ai.WhisperModel", return_value=MagicMock()), - patch("voip.ai.TTSModel") as tts_cls, - ): - tts_cls.load_model.return_value = tts_mock - call = AgentCall( - rtp=MagicMock(), - sip=MagicMock(), - media=OPUS_MEDIA, - voice="alba", - caller=CallerID(""), - ) - tts_mock.get_state_for_audio_prompt.assert_called_once_with("alba") - assert call._voice_state is voice_state - - @pytest.mark.asyncio - async def test_init__initializes_pending_state(self): - """AgentCall starts with an empty response task.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - assert call._response_task is None - - @pytest.mark.asyncio - async def test_init__initializes_chat_history_with_system_prompt(self): - """Chat history is seeded with a system prompt mentioning a phone call.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - assert len(call._messages) == 2 - assert call._messages[0]["role"] == "system" - assert "phone" in call._messages[0]["content"].lower() - - @pytest.mark.asyncio - async def test_transcription_received__ignores_empty_text(self): - """transcription_received appends empty text and creates a response task.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - with patch( - "voip.ai.asyncio.create_task", - side_effect=lambda c: c.close() or MagicMock(), - ) as mock_ct: - call.transcription_received("") - mock_ct.assert_called_once() - assert {"role": "user", "content": ""} in call._messages - - @pytest.mark.asyncio - async def test_transcription_received__buffers_non_empty_text(self): - """transcription_received appends a user message and creates a response task.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - with patch( - "voip.ai.asyncio.create_task", - side_effect=lambda c: c.close() or MagicMock(), - ) as mock_ct: - call.transcription_received("hello") - assert {"role": "user", "content": "hello"} in call._messages - mock_ct.assert_called_once() - - @pytest.mark.asyncio - async def test_transcription_received__schedules_response_task(self): - """transcription_received creates and stores a response task.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - task_mock = MagicMock() - with patch( - "voip.ai.asyncio.create_task", side_effect=lambda c: c.close() or task_mock - ) as mock_ct: - call.transcription_received("hello world") - mock_ct.assert_called_once() - assert call._response_task is task_mock - - @pytest.mark.asyncio - async def test_transcription_received__cancels_running_task_before_creating_new( - self, - ): - """transcription_received cancels any existing response task.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - old_task = MagicMock() - old_task.done.return_value = False - call._response_task = old_task - with patch("voip.ai.asyncio.create_task", side_effect=lambda c: c.close()): - call.transcription_received("hello") - old_task.cancel.assert_called_once() - - async def test_respond__calls_ollama_and_sends_speech(self): - """Respond fetches an Ollama reply, records it in history, and sends speech.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - call._messages.append({"role": "user", "content": "hello"}) - mock_response = MagicMock() - mock_response.message.content = "I am an AI assistant." - with ( - patch("voip.ai.ollama.AsyncClient") as mock_client_cls, - patch.object( - call, "send_speech", new_callable=AsyncMock - ) as mock_send_speech, - ): - mock_client = MagicMock() - mock_client.chat = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - await call.respond() - - mock_send_speech.assert_awaited_once_with("I am an AI assistant.") - assert {"role": "user", "content": "hello"} in call._messages - assert { - "role": "assistant", - "content": "I am an AI assistant.", - } in call._messages - - async def test_respond__passes_full_history_to_ollama(self): - """Respond passes the full message history (including system prompt) to Ollama.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - call._messages.append({"role": "user", "content": "hello"}) - mock_response = MagicMock() - mock_response.message.content = "reply" - with ( - patch("voip.ai.ollama.AsyncClient") as mock_client_cls, - patch.object(call, "send_speech", new_callable=AsyncMock), - ): - mock_client = MagicMock() - mock_client.chat = AsyncMock(return_value=mock_response) - mock_client_cls.return_value = mock_client - await call.respond() - _, kwargs = mock_client.chat.call_args - messages = kwargs.get("messages") or mock_client.chat.call_args[0][0] - # First message is the system prompt - assert messages[0]["role"] == "system" - assert messages[2] == {"role": "user", "content": "hello"} - - async def test_respond__raises_exception_on_error(self): - """Exceptions from Ollama propagate out of respond().""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - call._messages.append({"role": "user", "content": "hello"}) - with ( - patch("voip.ai.ollama.AsyncClient") as mock_client_cls, - pytest.raises(RuntimeError, match="ollama error"), - ): - mock_client = MagicMock() - mock_client.chat = AsyncMock(side_effect=RuntimeError("ollama error")) - mock_client_cls.return_value = mock_client - await call.respond() - - async def test_respond__re_raises_cancelled_error(self): - """Re-raise CancelledError from Ollama.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - call._messages.append({"role": "user", "content": "hello"}) - with ( - patch("voip.ai.ollama.AsyncClient") as mock_client_cls, - pytest.raises(asyncio.CancelledError), - ): - mock_client = MagicMock() - mock_client.chat = AsyncMock(side_effect=asyncio.CancelledError()) - mock_client_cls.return_value = mock_client - await call.respond() - - def test_preferred_codecs__opus_is_first(self): - """AgentCall prefers Opus as the highest-priority outbound codec.""" - assert AgentCall.supported_codecs[0].payload_type == RTPPayloadType.OPUS - - async def test_initial_prompt__schedules_send_speech_on_connect(self): - """AgentCall sends speech immediately when initial_prompt is set on construction.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - audio_mock = MagicMock() - audio_mock.numpy.return_value = np.zeros(16000, dtype=np.float32) - tts_mock.generate_audio.return_value = audio_mock - tts_mock.sample_rate = 22050 - - speeches: list[str] = [] - - class CapturingAgentCall(AgentCall): - async def send_speech(self, text: str) -> None: - speeches.append(text) - - make_agent_call( - MagicMock(), - tts_mock, - call_class=CapturingAgentCall, - media=PCMA_MEDIA, - salutation="Hello, how can I help?", - ) - await asyncio.sleep(0) - - assert speeches == ["Hello, how can I help?"] - - @pytest.mark.asyncio - async def test_initial_prompt__empty_does_not_schedule_send_speech(self): - """AgentCall with initial_prompt='' does not create a send_speech task.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - call = make_agent_call(MagicMock(), tts_mock) - # No task created means no asyncio activity beyond __post_init__ - assert call.salutation == "Hi." - - -class TestSayCall: - """Tests for SayCall.""" - - def test_say_call__is_audio_call(self): - """SayCall is a subclass of AudioCall.""" - assert issubclass(SayCall, AudioCall) - - def test_say_call__stores_text(self): - """SayCall stores the text to say.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - audio_mock = MagicMock() - audio_mock.numpy.return_value = np.zeros(0, dtype=np.float32) - tts_mock.generate_audio.return_value = audio_mock - tts_mock.sample_rate = 8000 - - with ( - patch("voip.ai.TTSModel") as tts_cls, - patch("asyncio.create_task"), - ): - tts_cls.load_model.return_value = tts_mock - call = SayCall( - rtp=MagicMock(), - sip=MagicMock(), - caller=CallerID("sip:bob@biloxi.com"), - media=PCMA_MEDIA, - text="Hello there!", - ) - - assert call.text == "Hello there!" - - def test_on_audio_sent__schedules_hang_up(self): - """on_audio_sent schedules hang_up via create_task.""" - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - audio_mock = MagicMock() - audio_mock.numpy.return_value = np.zeros(0, dtype=np.float32) - tts_mock.generate_audio.return_value = audio_mock - tts_mock.sample_rate = 8000 - - with ( - patch("voip.ai.TTSModel") as tts_cls, - patch("asyncio.create_task"), - ): - tts_cls.load_model.return_value = tts_mock - call = SayCall( - rtp=MagicMock(), - sip=MagicMock(), - caller=CallerID("sip:bob@biloxi.com"), - media=PCMA_MEDIA, - text="Hello!", - ) - - with patch( - "voip.ai.asyncio.create_task", - side_effect=lambda c: c.close() or MagicMock(), - ) as mock_create_task: - call.on_audio_sent() - - mock_create_task.assert_called_once() - - async def test_hang_up__sends_bye_and_closes_sip(self): - """hang_up sends BYE and closes the SIP transport.""" - from voip.rtp import RealtimeTransportProtocol - from voip.sip.messages import Dialog - - tts_mock = MagicMock() - tts_mock.get_state_for_audio_prompt.return_value = MagicMock() - audio_mock = MagicMock() - audio_mock.numpy.return_value = np.zeros(0, dtype=np.float32) - tts_mock.generate_audio.return_value = audio_mock - tts_mock.sample_rate = 8000 - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} - dialog = Dialog( - call_id="say-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - outbound_cseq=2, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - - with ( - patch("voip.ai.TTSModel") as tts_cls, - patch("asyncio.create_task"), - ): - tts_cls.load_model.return_value = tts_mock - call = SayCall( - rtp=mock_rtp, - sip=mock_sip, - caller=CallerID("sip:bob@biloxi.com"), - media=PCMA_MEDIA, - text="Hello!", - dialog=dialog, - ) - - await call.hang_up() - mock_sip.send.assert_called_once() - mock_sip.close.assert_called_once() diff --git a/tests/test_audio.py b/tests/test_audio.py deleted file mode 100644 index d41a824..0000000 --- a/tests/test_audio.py +++ /dev/null @@ -1,703 +0,0 @@ -"""Tests for audio call handler and codec utilities.""" - -import asyncio -import datetime -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -np = pytest.importorskip("numpy") -av = pytest.importorskip("av") - -from voip.audio import AudioCall, EchoCall, VoiceActivityCall # noqa: E402 -from voip.codecs.g722 import G722 # noqa: E402 -from voip.codecs.opus import Opus # noqa: E402 -from voip.codecs.pcma import PCMA # noqa: E402 -from voip.codecs.pcmu import PCMU # noqa: E402 -from voip.rtp import RealtimeTransportProtocol, RTPPayloadType # noqa: E402 -from voip.sdp.types import MediaDescription, RTPPayloadFormat # noqa: E402 -from voip.sip.types import CallerID # noqa: E402 - - -def _make_media(fmt: str, rtpmap: str | None = None) -> MediaDescription: - """Build a single-codec MediaDescription for use in tests.""" - if rtpmap: - payload_format = RTPPayloadFormat.parse(rtpmap) - else: - payload_format = RTPPayloadFormat(payload_type=int(fmt)) - return MediaDescription( - media="audio", port=0, proto="RTP/AVP", fmt=[payload_format] - ) - - -OPUS_MEDIA = _make_media("111", "111 opus/48000/2") -PCMA_MEDIA = _make_media("8", "8 PCMA/8000") -PCMU_MEDIA = _make_media("0") # static PT, no rtpmap -G722_MEDIA = _make_media("9", "9 G722/8000") - - -def make_audio_call(**kwargs) -> AudioCall: - """Create an AudioCall with mock rtp/sip for unit testing.""" - defaults: dict = { - "rtp": MagicMock(spec=RealtimeTransportProtocol), - "sip": MagicMock(), - "media": PCMA_MEDIA, - "caller": CallerID(""), - } - defaults.update(kwargs) - return AudioCall(**defaults) - - -class TestAudioCall: - def test_caller__returns_caller_arg(self): - """Return the caller string passed at construction.""" - call = make_audio_call(caller="sip:bob@biloxi.com") - assert call.caller == "sip:bob@biloxi.com" - - def test_caller__defaults_to_empty_string(self): - """Return an empty string when no caller is given.""" - assert make_audio_call().caller == "" - - def test_audio_received__noop_by_default(self): - """audio_received is a no-op in the base AudioCall class.""" - make_audio_call().audio_received(audio=np.array([]), rms=0.0) # must not raise - - def test_rtp_and_sip_stored_as_fields(self): - """Rtp and sip back-references are stored as dataclass fields.""" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_sip = MagicMock() - call = AudioCall( - rtp=mock_rtp, sip=mock_sip, media=PCMA_MEDIA, caller=CallerID("") - ) - assert call.rtp is mock_rtp - assert call.sip is mock_sip - - def test_init__stores_media(self): - """Media parameter is stored on the AudioCall instance.""" - media = MediaDescription( - media="audio", - port=49170, - proto="RTP/AVP", - fmt=[ - RTPPayloadFormat(payload_type=8, encoding_name="PCMA", sample_rate=8000) - ], - ) - call = make_audio_call(media=media) - assert call.media is media - - def test_init__derives_sample_rate_from_media(self): - """sample_rate is derived from the RTPPayloadFormat sample_rate.""" - media = MediaDescription( - media="audio", - port=49170, - proto="RTP/AVP", - fmt=[ - RTPPayloadFormat(payload_type=9, encoding_name="G722", sample_rate=8000) - ], - ) - call = make_audio_call(media=media) - assert call.sample_rate == 8000 - - def test_init__default_sample_rate_without_media(self): - """Default sample_rate is 8000 Hz for G.711 codecs.""" - assert make_audio_call().sample_rate == 8000 - - def test_init__derives_payload_type_from_media(self): - """payload_type is derived from the first fmt entry of the MediaDescription.""" - media = MediaDescription( - media="audio", - port=49170, - proto="RTP/AVP", - fmt=[RTPPayloadFormat(payload_type=8)], - ) - call = make_audio_call(media=media) - assert call.payload_type == 8 - - def test_init__default_payload_type_without_media(self): - """Default payload_type is 8 (PCMA) when using the default test media.""" - assert make_audio_call().payload_type == 8 - - @pytest.mark.asyncio - async def test_packet_received__dispatches_audio_for_non_empty_payload(self): - """packet_received schedules audio decoding when the packet has a payload.""" - from voip.rtp import RTPPacket # noqa: PLC0415 - - received: list = [] - - class ConcreteCall(AudioCall): - def decode_payload(self, packet: bytes) -> np.ndarray: - return np.array([1.0], dtype=np.float32) - - def audio_received(self, *, audio: np.ndarray, rms: float) -> None: - received.append(audio) - - packet = RTPPacket( - payload_type=8, sequence_number=1, timestamp=0, ssrc=0, payload=b"audio" - ) - call = ConcreteCall( - rtp=MagicMock(), sip=MagicMock(), media=PCMA_MEDIA, caller=CallerID("") - ) - call.packet_received(packet, ("127.0.0.1", 5004)) - await asyncio.sleep(0.05) - assert len(received) == 1 - - @pytest.mark.asyncio - async def test_packet_received__ignores_empty_payload(self): - """packet_received does not schedule decoding when the payload is empty.""" - from voip.rtp import RTPPacket # noqa: PLC0415 - - received: list = [] - - class ConcreteCall(AudioCall): - def audio_received(self, *, audio: np.ndarray, rms: float) -> None: - received.append(audio) - - packet = RTPPacket( - payload_type=8, sequence_number=1, timestamp=0, ssrc=0, payload=b"" - ) - call = ConcreteCall( - rtp=MagicMock(), sip=MagicMock(), media=PCMA_MEDIA, caller=CallerID("") - ) - call.packet_received(packet, ("127.0.0.1", 5004)) - await asyncio.sleep(0.05) - assert len(received) == 0 - - -class TestNegotiateCodec: - def _make_media(self, fmts: list[str], rtpmaps: list[str] | None = None): - """Build a MediaDescription with given format list and optional rtpmap attributes.""" - rtpmap_by_pt: dict[int, RTPPayloadFormat] = {} - for rtpmap in rtpmaps or []: - f = RTPPayloadFormat.parse(rtpmap) - rtpmap_by_pt[f.payload_type] = f - formats = [ - rtpmap_by_pt.get(int(pt)) or RTPPayloadFormat(payload_type=int(pt)) - for pt in fmts - ] - return MediaDescription(media="audio", port=49170, proto="RTP/AVP", fmt=formats) - - def test_negotiate_codec__prefers_opus(self): - """Select Opus when offered alongside lower-priority codecs.""" - media = self._make_media(["0", "8", "111"], ["111 opus/48000/2", "8 PCMA/8000"]) - result = AudioCall.negotiate_codec(media) - assert result.fmt[0].payload_type == 111 - assert result.fmt[0].sample_rate == 48000 - - def test_negotiate_codec__falls_back_to_pcma(self): - """Select PCMA when Opus and G.722 are not offered.""" - media = self._make_media(["0", "8"]) - result = AudioCall.negotiate_codec(media) - assert result.fmt[0].payload_type == 8 - assert result.fmt[0].sample_rate == 8000 - - def test_negotiate_codec__falls_back_to_pcmu(self): - """Select PCMU when only PCMU is offered.""" - media = self._make_media(["0"]) - result = AudioCall.negotiate_codec(media) - assert result.fmt[0].payload_type == 0 - - def test_negotiate_codec__matches_by_encoding_name_when_payload_type_differs(self): - """Select a codec by encoding name when its dynamic payload type differs from preferred.""" - # Dynamic PT 99 is not in preferred PTs, but encoding name "opus" matches. - media = self._make_media(["99"], ["99 opus/48000/2"]) - result = AudioCall.negotiate_codec(media) - assert result.fmt[0].encoding_name.lower() == "opus" - - def test_negotiate_codec__empty_fmt__raises(self): - """Raise NotImplementedError when the remote side offers no audio formats.""" - media = self._make_media([]) - with pytest.raises(NotImplementedError): - AudioCall.negotiate_codec(media) - - def test_negotiate_codec__unknown_codec__raises(self): - """Raise NotImplementedError when no offered codec matches PREFERRED_CODECS.""" - media = self._make_media(["126"], ["126 telephone-event/8000"]) - with pytest.raises(NotImplementedError): - AudioCall.negotiate_codec(media) - - def test_negotiate_codec__returns_media_description(self): - """negotiate_codec returns a MediaDescription object.""" - media = self._make_media(["0", "8", "111"], ["111 opus/48000/2"]) - result = AudioCall.negotiate_codec(media) - assert isinstance(result, MediaDescription) - assert result.media == "audio" - assert result.proto == "RTP/AVP" - - def test_negotiate_codec__subclass_can_override_preferences(self): - """A subclass with a different PREFERRED_CODECS list uses its own preferences.""" - - class PCMAOnlyCall(AudioCall): - supported_codecs = [PCMA] - - media = self._make_media(["0", "8", "111"]) - result = PCMAOnlyCall.negotiate_codec(media) - assert result.fmt[0].payload_type == 8 - - def test_preferred_codecs__class_attribute(self): - """PREFERRED_CODECS is a class attribute on AudioCall with Opus first when PyAV is available.""" - codec_classes = AudioCall.supported_codecs - assert isinstance(codec_classes, list) - pts = [c.payload_type for c in codec_classes] - assert pts[0] == 111 # Opus is highest priority when PyAV is present - assert 8 in pts # PCMA present - assert 0 in pts # PCMU present - - -class TestCodecAssignment: - """Tests that __post_init__ assigns the correct codec class.""" - - def test_opus_media__codec_is_opus(self): - """Opus media assigns the Opus codec class.""" - call = make_audio_call(media=OPUS_MEDIA) - assert call.codec is Opus - assert call.codec.sample_rate_hz == 48000 - assert call.codec.frame_size == 960 - assert call.codec.timestamp_increment == 960 - - def test_g722_media__codec_is_g722(self): - """G.722 media assigns the G722 codec class with correct rates.""" - call = make_audio_call(media=G722_MEDIA) - assert call.codec is G722 - assert call.codec.sample_rate_hz == 16000 - assert call.codec.frame_size == 320 - assert call.codec.timestamp_increment == 160 - - def test_pcmu_media__codec_is_pcmu(self): - """PCMU media assigns the PCMU codec class.""" - call = make_audio_call(media=PCMU_MEDIA) - assert call.codec is PCMU - assert call.codec.sample_rate_hz == 8000 - assert call.codec.frame_size == 160 - assert call.codec.timestamp_increment == 160 - - def test_pcma_media__codec_is_pcma(self): - """PCMA media assigns the PCMA codec class.""" - call = make_audio_call(media=PCMA_MEDIA) - assert call.codec is PCMA - - -class TestResample: - """Tests for AudioCall.resample.""" - - def test_resample__downsamples_from_24khz_to_8khz(self): - """Resample reduces 24 000 samples at 24 kHz to 8 000 samples at 8 kHz.""" - audio = np.zeros(24000, dtype=np.float32) - assert len(AudioCall.resample(audio, 24000, 8000)) == 8000 - - def test_resample__passthrough_when_rate_matches(self): - """Resample returns the original array unchanged when rates are equal.""" - audio = np.zeros(8000, dtype=np.float32) - assert AudioCall.resample(audio, 8000, 8000) is audio - - -class TestDecodePayload: - """Tests for AudioCall.decode_payload.""" - - def test_decode_payload__delegates_to_codec(self): - """decode_payload routes through PerPacketDecoder which calls codec.decode.""" - call = make_audio_call(media=PCMA_MEDIA) - with patch.object( - PCMA, "decode", return_value=np.zeros(16000, dtype=np.float32) - ) as mock_decode: - call.decode_payload(b"payload") - mock_decode.assert_called_once_with( - b"payload", - call.sampling_rate_hz, - input_rate_hz=call.sample_rate, - ) - - def test_decode_payload__passes_sample_rate_from_media(self): - """decode_payload passes the SDP-negotiated sample rate as input_rate_hz.""" - wideband_pcma = _make_media("8", "8 PCMA/16000") - call = make_audio_call(media=wideband_pcma) - assert call.sample_rate == 16000 - with patch.object( - PCMA, "decode", return_value=np.zeros(16000, dtype=np.float32) - ) as mock_decode: - call.decode_payload(b"pkt") - mock_decode.assert_called_once_with( - b"pkt", - call.sampling_rate_hz, - input_rate_hz=16000, - ) - - def test_decode_payload__raises_for_unsupported_codec(self): - """Raise NotImplementedError when constructed with an unsupported codec.""" - media = MediaDescription( - media="audio", - port=0, - proto="RTP/AVP", - fmt=[ - RTPPayloadFormat( - payload_type=96, encoding_name="speex", sample_rate=8000 - ) - ], - ) - with pytest.raises(NotImplementedError, match="Unsupported codec"): - make_audio_call(media=media) - - def test_decode_payload__g722_uses_stateful_decoder(self): - """G.722 AudioCall uses a G722Decoder that preserves ADPCM state.""" - from voip.codecs.g722 import G722Decoder # noqa: PLC0415 - - call = make_audio_call(media=G722_MEDIA) - assert isinstance(call.payload_decoder, G722Decoder) - - def test_decode_payload__pcma_uses_per_packet_decoder(self): - """PCMA AudioCall uses a PerPacketDecoder (stateless).""" - from voip.codecs.base import PerPacketDecoder # noqa: PLC0415 - - call = make_audio_call(media=PCMA_MEDIA) - assert isinstance(call.payload_decoder, PerPacketDecoder) - - -class TestAudioCallInit: - def test_init__raises_value_error_for_none_encoding_name(self): - """Raise ValueError when the negotiated format has no encoding name.""" - media = MediaDescription( - media="audio", - port=0, - proto="RTP/AVP", - fmt=[ - RTPPayloadFormat(payload_type=96) - ], # dynamic PT, no rtpmap -> no encoding name - ) - with pytest.raises(ValueError, match="No encoding name"): - make_audio_call(media=media) - - -class TestNextRTPPacket: - """Tests for AudioCall.next_rtp_packet.""" - - def test_next_rtp_packet__has_twelve_byte_header(self): - """next_rtp_packet produces a packet whose build() has a 12-byte RTP header.""" - call = make_audio_call(media=PCMU_MEDIA) - data = bytes(call.next_rtp_packet(b"\x00" * 160)) - assert len(data) == 12 + 160 - assert data[0] == 0x80 # V=2, P=0, X=0, CC=0 - - def test_next_rtp_packet__increments_seq_and_ts_each_call(self): - """Each next_rtp_packet call increments seq by 1 and ts by chunk size.""" - call = make_audio_call(media=PCMU_MEDIA) - call.next_rtp_packet(b"\x00" * 160) - assert call.rtp_sequence_number == 1 - assert call.rtp_timestamp == 160 - call.next_rtp_packet(b"\x00" * 160) - assert call.rtp_sequence_number == 2 - assert call.rtp_timestamp == 320 - - def test_next_rtp_packet__uses_negotiated_payload_type(self): - """next_rtp_packet uses the negotiated payload type in the RTP header.""" - call = make_audio_call(media=PCMA_MEDIA) - assert bytes(call.next_rtp_packet(b"\x00" * 160))[1] == RTPPayloadType.PCMA - - -class TestSendRTPAudio: - """Tests for AudioCall.send_rtp_audio.""" - - async def test_send_rtp_audio__sends_to_remote_addr(self): - """The first RTP packet is transmitted synchronously inside send_audio.""" - call = make_audio_call(media=PCMU_MEDIA) - remote_addr = ("10.0.0.1", 5004) - call.rtp.calls = {remote_addr: call} - - with patch.object(call, "send_packet") as mock_send: - await call.send_audio(np.zeros(160, dtype=np.float32)) - mock_send.assert_called_once() - data, addr = mock_send.call_args[0] - assert addr == remote_addr - assert len(bytes(data)) == 12 + 160 # 12-byte RTP header + 160 PCMU bytes - - async def test_send_rtp_audio__drops_audio_when_no_remote_addr(self, caplog): - """Log a warning and drop audio when no RTP address is registered.""" - import logging # noqa: PLC0415 - - call = make_audio_call() - call.rtp.calls = {} - - with ( - caplog.at_level(logging.WARNING, logger="voip.audio"), - patch.object(call, "send_packet") as mock_send, - ): - await call.send_audio(np.zeros(160, dtype=np.float32)) - mock_send.assert_not_called() - assert any("dropping audio" in r.message for r in caplog.records) - - async def test_send_rtp_audio__paces_packets_at_20ms_intervals(self): - """Subsequent packets are scheduled at rpt_packet_duration intervals via call_later.""" - call = make_audio_call(media=PCMU_MEDIA) - remote_addr = ("10.0.0.2", 5006) - call.rtp.calls = {remote_addr: call} - - with patch.object(call, "send_packet"): - await call.send_audio(np.zeros(320, dtype=np.float32)) - - loop = asyncio.get_event_loop() - assert call.outbound_handle is not None - assert call.outbound_handle.when() == pytest.approx( - loop.time() + call.rpt_packet_duration.total_seconds(), abs=0.01 - ) - - async def test_cancel_outbound_audio__cancels_handle_and_clears(self): - """cancel_outbound_audio cancels the pending handle and sets outbound_handle to None.""" - call = make_audio_call(media=PCMU_MEDIA) - remote_addr = ("10.0.0.1", 5004) - call.rtp.calls = {remote_addr: call} - - with patch.object(call, "send_packet"): - await call.send_audio(np.zeros(320, dtype=np.float32)) - - handle = call.outbound_handle - call.cancel_outbound_audio() - - assert handle.cancelled() - assert call.outbound_handle is None - - async def test_send_audio__preempts_pending_handle(self): - """A second send_audio cancels the pending handle from the first call.""" - call = make_audio_call(media=PCMU_MEDIA) - remote_addr = ("10.0.0.1", 5004) - call.rtp.calls = {remote_addr: call} - - with patch.object(call, "send_packet"): - await call.send_audio(np.zeros(320, dtype=np.float32)) - first_handle = call.outbound_handle - await call.send_audio(np.zeros(320, dtype=np.float32)) - - assert first_handle.cancelled() - - async def test_on_audio_sent__called_when_all_packets_dispatched(self): - """on_audio_sent is invoked once all packets from send_audio are dispatched.""" - call = make_audio_call(media=PCMU_MEDIA) - remote_addr = ("10.0.0.1", 5004) - call.rtp.calls = {remote_addr: call} - received: list[bool] = [] - - original_on_audio_sent = call.on_audio_sent - - def capturing_on_audio_sent(): - received.append(True) - original_on_audio_sent() - - call.on_audio_sent = capturing_on_audio_sent - - with patch.object(call, "send_packet"): - await call.send_audio(np.zeros(160, dtype=np.float32)) - - # The second _dispatch_next_packet fires after rpt_packet_duration (20 ms) - await asyncio.sleep(0.1) - - assert received == [True] - - async def test_on_audio_sent__not_called_while_packets_remain(self): - """on_audio_sent is not called until the last packet is dispatched.""" - call = make_audio_call(media=PCMU_MEDIA) - remote_addr = ("10.0.0.1", 5004) - call.rtp.calls = {remote_addr: call} - received: list[bool] = [] - - def capturing_on_audio_sent(): - received.append(True) - - call.on_audio_sent = capturing_on_audio_sent - - with patch.object(call, "send_packet"): - # Two 160-sample chunks → two packets, second is deferred - await call.send_audio(np.zeros(320, dtype=np.float32)) - - # outbound_handle is still pending — on_audio_sent must not have fired yet - assert call.outbound_handle is not None - assert received == [] - - -def make_echo_call(**kwargs) -> EchoCall: - """Create an EchoCall with mock rtp/sip for unit testing.""" - defaults: dict = { - "rtp": MagicMock(spec=RealtimeTransportProtocol), - "sip": MagicMock(), - "media": PCMU_MEDIA, - "caller": CallerID(""), - } - defaults.update(kwargs) - return EchoCall(**defaults) - - -def make_vac_call(**kwargs) -> VoiceActivityCall: - """Create a VoiceActivityCall with mock rtp/sip for unit testing.""" - defaults: dict = { - "rtp": MagicMock(spec=RealtimeTransportProtocol), - "sip": MagicMock(), - "media": PCMU_MEDIA, - "caller": CallerID(""), - } - defaults.update(kwargs) - return VoiceActivityCall(**defaults) - - -class TestVoiceActivityCall: - """Tests for the shared VAD infrastructure in VoiceActivityCall.""" - - def test_voice_activity_call__is_audio_call(self): - """VoiceActivityCall is a subclass of AudioCall.""" - assert issubclass(VoiceActivityCall, AudioCall) - - def test_audio_received__appends_to_speech_buffer(self): - """audio_received concatenates frames to the speech buffer regardless of RMS.""" - call = make_vac_call() - audio = np.ones(160, dtype=np.float32) * 0.5 - with patch("voip.audio.asyncio.get_event_loop"): - call.audio_received(audio=audio, rms=0.5) - assert call._speech_buffer.size == 160 - - def test_audio_received__speech_cancels_flush_timer(self): - """audio_received with speech-level RMS cancels any running flush timer.""" - call = make_vac_call() - handle = MagicMock() - call._flush_voice_buffer_handle = handle - call.audio_received(audio=np.ones(160, dtype=np.float32), rms=1.0) - handle.cancel.assert_called_once() - assert call._flush_voice_buffer_handle is None - - def test_audio_received__silence_arms_flush_timer(self): - """audio_received with silence-level RMS schedules the flush timer once.""" - call = make_vac_call() - with patch("voip.audio.asyncio.get_event_loop") as mock_loop: - handle = MagicMock() - mock_loop.return_value.call_later.return_value = handle - call.audio_received(audio=np.zeros(160, dtype=np.float32), rms=0.0) - mock_loop.return_value.call_later.assert_called_once_with( - call.silence_gap.total_seconds(), - call.flush_voice_buffer, - ) - assert call._flush_voice_buffer_handle is handle - - def test_audio_received__silence_does_not_rearm_when_timer_running(self): - """A second silence frame does not replace a running flush timer.""" - call = make_vac_call() - call._flush_voice_buffer_handle = MagicMock() - with patch("voip.audio.asyncio.get_event_loop") as mock_loop: - call.audio_received(audio=np.zeros(160, dtype=np.float32), rms=0.0) - mock_loop.return_value.call_later.assert_not_called() - - def test_on_audio_speech__cancels_flush_handle(self): - """on_audio_speech cancels the flush timer when one is running.""" - call = make_vac_call() - handle = MagicMock() - call._flush_voice_buffer_handle = handle - call.on_audio_speech() - handle.cancel.assert_called_once() - assert call._flush_voice_buffer_handle is None - - def test_on_audio_speech__noop_when_no_timer(self): - """on_audio_speech does nothing when no flush timer is running.""" - call = make_vac_call() - call.on_audio_speech() # must not raise - assert call._flush_voice_buffer_handle is None - - @pytest.mark.asyncio - async def test_on_audio_silence__arms_timer(self): - """on_audio_silence schedules the flush timer via the event loop.""" - call = make_vac_call() - call.on_audio_silence() - assert call._flush_voice_buffer_handle is not None - call._flush_voice_buffer_handle.cancel() - - @pytest.mark.asyncio - async def test_on_audio_silence__noop_when_timer_already_running(self): - """on_audio_silence does not replace a running flush timer.""" - call = make_vac_call() - call.on_audio_silence() - first_handle = call._flush_voice_buffer_handle - call.on_audio_silence() - assert call._flush_voice_buffer_handle is first_handle - call._flush_voice_buffer_handle.cancel() - - @pytest.mark.asyncio - async def test_flush_voice_buffer__clears_buffer_and_resets_handle(self): - """flush_voice_buffer resets the handle and clears the speech buffer.""" - call = make_vac_call() - call._speech_buffer = np.ones(call.sampling_rate_hz * 2, dtype=np.float32) - call.flush_voice_buffer() - assert call._flush_voice_buffer_handle is None - assert call._speech_buffer.size == 0 - - @pytest.mark.asyncio - async def test_flush_voice_buffer__schedules_voice_received_for_loud_audio(self): - """flush_voice_buffer schedules voice_received for utterances above RMS threshold.""" - call = make_vac_call() - # Fill buffer with 2 seconds of loud audio. - call._speech_buffer = np.ones(call.sampling_rate_hz * 2, dtype=np.float32) - with patch("voip.audio.asyncio.create_task") as mock_ct: - call.flush_voice_buffer() - mock_ct.assert_called_once() - - def test_flush_voice_buffer__discards_short_utterances(self): - """flush_voice_buffer drops utterances shorter than silence_gap seconds.""" - call = make_vac_call() - # 10 samples is much shorter than sampling_rate_hz * silence_gap_secs. - call._speech_buffer = np.ones(10, dtype=np.float32) - with patch("voip.audio.asyncio.create_task") as mock_ct: - call.flush_voice_buffer() - mock_ct.assert_not_called() - - def test_flush_voice_buffer__discards_quiet_utterances(self): - """flush_voice_buffer drops utterances below utterances_rms_threshold.""" - call = make_vac_call() - # Buffer long enough but nearly silent. - call._speech_buffer = np.zeros(call.sampling_rate_hz * 2, dtype=np.float32) - with patch("voip.audio.asyncio.create_task") as mock_ct: - call.flush_voice_buffer() - mock_ct.assert_not_called() - - @pytest.mark.asyncio - async def test_voice_received__noop_in_base(self): - """voice_received is a no-op in the base VoiceActivityCall.""" - call = make_vac_call() - await call.voice_received(np.zeros(160, dtype=np.float32)) # must not raise - - -class TestEchoCall: - """Tests for EchoCall speech echo playback.""" - - def test_echo_call__is_voice_activity_call(self): - """EchoCall is a subclass of VoiceActivityCall.""" - assert issubclass(EchoCall, VoiceActivityCall) - - @pytest.mark.asyncio - async def test_voice_received__sends_resampled_audio(self): - """voice_received resamples from sampling_rate_hz to codec rate and sends via RTP.""" - call = make_echo_call(media=PCMU_MEDIA) - audio = np.ones(160, dtype=np.float32) - with patch.object(call, "send_audio", new_callable=AsyncMock) as mock_send: - await call.voice_received(audio) - mock_send.assert_awaited_once() - sent_audio = mock_send.call_args[0][0] - # PCMU sample_rate_hz == 8000; sampling_rate_hz == 16000 → half length - expected_len = round( - len(audio) * call.codec.sample_rate_hz / call.sampling_rate_hz - ) - assert len(sent_audio) == expected_len - - @pytest.mark.asyncio - async def test_audio_received__echoes_after_sustained_silence(self): - """Speech followed by silence_gap of silence triggers echo playback.""" - call = make_echo_call( - silence_gap=datetime.timedelta(milliseconds=10), - ) - remote_addr = ("10.0.0.1", 5004) - call.rtp.calls = {remote_addr: call} - - speech = np.ones(160, dtype=np.float32) * 0.5 - silence = np.zeros(160, dtype=np.float32) - - sent: list[np.ndarray] = [] - - async def capture_send(audio: np.ndarray) -> None: - sent.append(audio) - - with patch.object(call, "send_audio", side_effect=capture_send): - call.audio_received(audio=speech, rms=1.0) - call.audio_received(audio=silence, rms=0.0) - await asyncio.sleep(0.05) - - assert len(sent) == 1 diff --git a/tests/test_rtp.py b/tests/test_rtp.py index 2293c92..a6e432d 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -517,297 +517,3 @@ def test_negotiate_codec__raises_not_implemented(self): """negotiate_codec raises NotImplementedError in the base class.""" with pytest.raises(NotImplementedError): Session.negotiate_codec(MagicMock()) - - async def test_hang_up__no_dialog_is_noop(self): - """hang_up is a no-op when no dialog is associated with the call.""" - call = make_call() - await call.hang_up() # must not raise - - async def test_hang_up__sends_bye(self): - """hang_up sends a BYE request when a fully established dialog is present.""" - from voip.sip.messages import Dialog - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} - dialog = Dialog( - call_id="test-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - outbound_cseq=2, - sip=mock_sip, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - mock_sip.transactions = {} - call = make_call(rtp=mock_rtp, dialog=dialog) - hang_task = asyncio.create_task(call.hang_up()) - await asyncio.sleep(0) - (tx,) = mock_sip.transactions.values() - tx.done.set() - await hang_task - mock_sip.send.assert_called_once() - sent = mock_sip.send.call_args[0][0] - assert b"BYE" in bytes(sent) - assert b"sip:bob@192.0.2.2" in bytes(sent) - assert b"CSeq: 2 BYE" in bytes(sent) - - async def test_hang_up__registers_bye_transaction(self): - """hang_up registers a ByeTransaction to handle the 200 OK acknowledgment.""" - from voip.sip.messages import Dialog - from voip.sip.transactions import ByeTransaction - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} - transactions: dict = {} - mock_sip.transactions = transactions - dialog = Dialog( - call_id="test-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - outbound_cseq=2, - sip=mock_sip, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - call = make_call(rtp=mock_rtp, dialog=dialog) - hang_task = asyncio.create_task(call.hang_up()) - await asyncio.sleep(0) - (tx,) = transactions.values() - assert isinstance(tx, ByeTransaction) - assert tx.cseq == 2 - tx.done.set() - await hang_task - - async def test_hang_up__bye_transaction_cleaned_up_on_200(self): - """ByeTransaction removes itself from sip.transactions when 200 OK arrives.""" - from voip.sip.messages import Dialog, Message - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} - transactions: dict = {} - mock_sip.transactions = transactions - dialog = Dialog( - call_id="test-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - outbound_cseq=2, - sip=mock_sip, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - call = make_call(rtp=mock_rtp, dialog=dialog) - hang_task = asyncio.create_task(call.hang_up()) - await asyncio.sleep(0) - (tx,) = transactions.values() - ok_response = Message.parse( - f"SIP/2.0 200 OK\r\n" - f"Via: SIP/2.0/TLS 192.0.2.1:5061;rport;branch={tx.branch}\r\n" - f"From: sip:alice@example.com;tag=our-tag\r\n" - f"To: sip:bob@biloxi.com;tag=callee-tag\r\n" - f"Call-ID: test-call@example.com\r\n" - f"CSeq: 2 BYE\r\n" - f"\r\n".encode() - ) - tx.response_received(ok_response) - await hang_task - assert tx.branch not in transactions - - async def test_hang_up__waits_for_bye_acknowledgment(self): - """hang_up blocks until the ByeTransaction is done.""" - from voip.sip.messages import Dialog - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} - mock_sip.transactions = {} - dialog = Dialog( - call_id="test-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - outbound_cseq=2, - sip=mock_sip, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - call = make_call(rtp=mock_rtp, dialog=dialog) - hang_task = asyncio.create_task(call.hang_up()) - await asyncio.sleep(0) - assert not hang_task.done(), "hang_up() should still be waiting for BYE ack" - (tx,) = mock_sip.transactions.values() - tx.done.set() - await hang_task - assert hang_task.done() - - async def test_hang_up__continues_after_bye_timeout(self): - """hang_up logs a warning and continues when BYE is not acknowledged in time.""" - from voip.sip.messages import Dialog - - class ShortTimeoutDialog(Dialog): - BYE_ACK_TIMEOUT = 0.001 # 1 ms — expire immediately in tests - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} - mock_sip.transactions = {} - dialog = ShortTimeoutDialog( - call_id="test-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - outbound_cseq=2, - sip=mock_sip, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - call = make_call(rtp=mock_rtp, dialog=dialog) - await call.hang_up() # must not raise despite no 200 OK - - async def test_hang_up__removes_dialog(self): - """hang_up removes the dialog from sip.dialogs.""" - from voip.sip.messages import Dialog - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} - mock_sip.transactions = {} - dialog = Dialog( - call_id="test-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - sip=mock_sip, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - call = make_call(rtp=mock_rtp, dialog=dialog) - hang_task = asyncio.create_task(call.hang_up()) - await asyncio.sleep(0) - (tx,) = mock_sip.transactions.values() - tx.done.set() - await hang_task - assert (dialog.remote_tag, dialog.local_tag) not in mock_sip.dialogs - - async def test_hang_up__deregisters_rtp_handler(self): - """hang_up unregisters the RTP call handler.""" - from voip.sip.messages import Dialog - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_sip.transactions = {} - dialog = Dialog( - call_id="test-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - sip=mock_sip, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - call = make_call(rtp=mock_rtp, dialog=dialog) - remote_addr = ("192.0.2.2", 5004) - mock_rtp.calls = {remote_addr: call} - hang_task = asyncio.create_task(call.hang_up()) - await asyncio.sleep(0) - (tx,) = mock_sip.transactions.values() - tx.done.set() - await hang_task - mock_rtp.unregister_call.assert_called_once_with(remote_addr) - - async def test_hang_up__deregisters_wildcard_rtp_handler(self): - """hang_up unregisters a wildcard (addr=None) RTP handler.""" - from voip.sip.messages import Dialog - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_sip.transactions = {} - dialog = Dialog( - call_id="test-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - sip=mock_sip, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - call = make_call(rtp=mock_rtp, dialog=dialog) - mock_rtp.calls = {None: call} # wildcard registration - hang_task = asyncio.create_task(call.hang_up()) - await asyncio.sleep(0) - (tx,) = mock_sip.transactions.values() - tx.done.set() - await hang_task - mock_rtp.unregister_call.assert_called_once_with(None) - - async def test_hang_up__skips_unregister_when_handler_not_in_rtp(self): - """hang_up does not call unregister_call when handler is not found.""" - from voip.sip.messages import Dialog - - mock_sip = MagicMock() - mock_sip.aor.transport = "TLS" - mock_sip.local_address = "192.0.2.1:5061" - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} # call not registered - mock_sip.transactions = {} - dialog = Dialog( - call_id="test-call@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - remote_contact="sip:bob@192.0.2.2", - sip=mock_sip, - ) - mock_sip.dialogs = {(dialog.remote_tag, dialog.local_tag): dialog} - call = make_call(rtp=mock_rtp, dialog=dialog) - hang_task = asyncio.create_task(call.hang_up()) - await asyncio.sleep(0) - (tx,) = mock_sip.transactions.values() - tx.done.set() - await hang_task - mock_rtp.unregister_call.assert_not_called() - - async def test_hang_up__missing_local_party_is_noop(self): - """hang_up logs a warning and returns when local_party is not set.""" - from voip.sip.messages import Dialog - - mock_sip = MagicMock() - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} - dialog = Dialog( - call_id="test@example.com", - remote_contact="sip:bob@192.0.2.2", - sip=mock_sip, - ) - call = make_call(rtp=mock_rtp, dialog=dialog) - await call.hang_up() - mock_sip.send.assert_not_called() - - async def test_hang_up__missing_remote_contact_is_noop(self): - """hang_up logs a warning and returns when remote_contact is not set.""" - from voip.sip.messages import Dialog - - mock_sip = MagicMock() - mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - mock_rtp.calls = {} - dialog = Dialog( - call_id="test@example.com", - local_party="sip:alice@example.com;tag=our-tag", - remote_party="sip:bob@biloxi.com;tag=callee-tag", - sip=mock_sip, - ) - call = make_call(rtp=mock_rtp, dialog=dialog) - await call.hang_up() - mock_sip.send.assert_not_called() diff --git a/voip/rtp.py b/voip/rtp.py index ee223ce..ee75f59 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -106,10 +106,7 @@ class Session: caller: Caller identifier as received in the SIP From header. media: Negotiated SDP media description for this call leg. srtp: Optional SRTP session for encrypting and decrypting media. - dialog: SIP dialog state for this call leg. Set by the transaction - layer after the call is established; used by - [`hang_up`][voip.rtp.Session.hang_up] to send BYE via - [`Dialog.bye`][voip.sip.messages.Dialog.bye]. + dialog: SIP dialog state for this call leg. """ rtp: RealtimeTransportProtocol @@ -144,7 +141,7 @@ async def hang_up(self) -> None: """Terminate the call by sending a SIP BYE request [RFC 3261 §15]. Deregisters this call from the RTP multiplexer, then delegates the - BYE signaling to [`Dialog.bye`][voip.sip.messages.Dialog.bye], which + BYE signaling to [`Dialog.bye`][voip.sip.dialog.Dialog.bye], which constructs and sends the BYE request, removes the dialog from the SIP session's registry, and awaits the 200 OK acknowledgment. diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index 3a74bfe..53d83c3 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -22,9 +22,9 @@ class Dialog: established by a non-final response to the INVITE, see also: [RFC 3261 §12]. Subclass `Dialog` to implement inbound call handling. Override - [`call_received`][voip.sip.messages.Dialog.call_received] and call - [`accept`][voip.sip.messages.Dialog.accept] or - [`reject`][voip.sip.messages.Dialog.reject] from within it. Register the + [`call_received`][voip.sip.dialog.Dialog.call_received] and call + [`accept`][voip.sip.dialog.Dialog.accept] or + [`reject`][voip.sip.dialog.Dialog.reject] from within it. Register the subclass as `dialog_class` on the SIP session: ```python @@ -38,7 +38,7 @@ class MySession(SessionInitiationProtocol): ``` For outbound calls, create a `Dialog` with the SIP session set and call - [`dial`][voip.sip.messages.Dialog.dial]: + [`dial`][voip.sip.dialog.Dialog.dial]: ```python dialog = Dialog(sip=my_sip_session) @@ -65,11 +65,7 @@ class MySession(SessionInitiationProtocol): sip: The SIP session that owns this dialog. Set by the transaction layer when the dialog is confirmed. invite_tx: The [`InviteTransaction`][voip.sip.transactions.InviteTransaction] - for an inbound INVITE. Set before - [`call_received`][voip.sip.messages.Dialog.call_received] is called - so that [`accept`][voip.sip.messages.Dialog.accept], - [`reject`][voip.sip.messages.Dialog.reject], and - [`ringing`][voip.sip.messages.Dialog.ringing] can delegate to it. + for an inbound INVITE. """ BYE_ACK_TIMEOUT: typing.ClassVar[float] = 32.0 @@ -137,9 +133,9 @@ def call_received(self) -> None: Called by the SIP layer after the dialog is created from the INVITE request. The base implementation rejects the call with ``486 Busy Here``. Override in subclasses to answer, ring, or reject the call - using [`accept`][voip.sip.messages.Dialog.accept], - [`ringing`][voip.sip.messages.Dialog.ringing], and - [`reject`][voip.sip.messages.Dialog.reject]. + using [`accept`][voip.sip.dialog.Dialog.accept], + [`ringing`][voip.sip.dialog.Dialog.ringing], and + [`reject`][voip.sip.dialog.Dialog.reject]. """ self.reject() @@ -155,9 +151,6 @@ def hangup_received(self) -> None: def ringing(self) -> None: """Send a 180 Ringing provisional response [RFC 3261 §21.1.2]. - Delegates to the [`InviteTransaction`][voip.sip.transactions.InviteTransaction] - set on [`invite_tx`][voip.sip.messages.Dialog.invite_tx]. - [RFC 3261 §21.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-21.1.2 """ if self.invite_tx is not None: @@ -191,18 +184,6 @@ def reject(self, status_code: types.SIPStatus = types.SIPStatus.BUSY_HERE) -> No async def bye(self) -> None: """Terminate the dialog by sending a SIP BYE request [RFC 3261 §15]. - Constructs and sends a BYE request, removes this dialog from the SIP - session's registry, and awaits the remote party's 200 OK - acknowledgment. The standard non-INVITE transaction timeout of - [`BYE_ACK_TIMEOUT`][voip.sip.messages.Dialog.BYE_ACK_TIMEOUT] seconds - applies; a warning is logged if no acknowledgment arrives in time. - - This is a no-op when [`sip`][voip.sip.messages.Dialog.sip] is not set, - or when [`local_party`][voip.sip.messages.Dialog.local_party], - [`remote_party`][voip.sip.messages.Dialog.remote_party], or - [`remote_contact`][voip.sip.messages.Dialog.remote_contact] are - missing (call not yet fully established). - [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 """ if self.sip is None: @@ -270,10 +251,6 @@ async def dial( ) -> None: """Initiate an outbound call to *target* [RFC 3261 §13.1]. - Requires [`sip`][voip.sip.messages.Dialog.sip] to be set. Sets - [`uac`][voip.sip.messages.Dialog.uac] from the SIP session's AOR when - not already provided. - Args: target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). call_class: Session subclass to create for this call. diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 789c5ad..6feace0 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -48,8 +48,8 @@ class SessionInitiationProtocol(asyncio.Protocol): authentication [RFC 3261 §22]. All signaling is sent over a single persistent TLS/TCP connection. - Subclass [`Dialog`][voip.sip.messages.Dialog] and override - [`call_received`][voip.sip.messages.Dialog.call_received] to handle + Subclass [`Dialog`][voip.sip.dialog.Dialog] and override + [`call_received`][voip.sip.dialog.Dialog.call_received] to handle inbound calls, then register it as `dialog_class`: ```python @@ -63,7 +63,7 @@ class MySession(SessionInitiationProtocol): ``` For outbound calls, use - [`Dialog.dial`][voip.sip.messages.Dialog.dial] from within + [`Dialog.dial`][voip.sip.dialog.Dialog.dial] from within [`on_registered`][voip.sip.protocol.SessionInitiationProtocol.on_registered]: ```python @@ -79,9 +79,9 @@ def on_registered(self) -> None: Args: aor: SIP Address of Record (AOR) to register with the carrier. rtp: Shared RTP mux for call media. - dialog_class: [`Dialog`][voip.sip.messages.Dialog] subclass used to + dialog_class: [`Dialog`][voip.sip.dialog.Dialog] subclass used to create dialogs for incoming calls. Defaults to the base - [`Dialog`][voip.sip.messages.Dialog] which rejects all calls with + [`Dialog`][voip.sip.dialog.Dialog] which rejects all calls with ``486 Busy Here``. registration_class: Transaction subclass to handle registration transactions. keepalive_interval: Keep-alive ping interval. Should be between 30 and 90 seconds. diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 08b46aa..06eff5a 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -344,8 +344,8 @@ class InviteTransaction(Transaction): SIP layer creates one instance per incoming INVITE, keyed by Via branch (RFC 3261 §17.1.3). - For inbound call handling, subclass [`Dialog`][voip.sip.messages.Dialog] - and override [`call_received`][voip.sip.messages.Dialog.call_received]: + For inbound call handling, subclass [`Dialog`][voip.sip.dialog.Dialog] + and override [`call_received`][voip.sip.dialog.Dialog.call_received]: ```python class MyDialog(Dialog): @@ -370,11 +370,6 @@ class MySession(SessionInitiationProtocol): def invite_received(self, request: Request) -> None: """Handle an incoming INVITE by delegating to the dialog. - Wires up [`dialog.invite_tx`][voip.sip.messages.Dialog.invite_tx] and - [`dialog.sip`][voip.sip.messages.Dialog.sip], then calls - [`dialog.call_received`][voip.sip.messages.Dialog.call_received] so - that application logic lives in the dialog subclass. - Args: request: The SIP INVITE request. """ @@ -398,7 +393,7 @@ def bye_received(self, request: Request) -> None: """Handle a BYE terminating a dialog. Removes the dialog from the registry, sends a 200 OK, and calls - [`dialog.hangup_received`][voip.sip.messages.Dialog.hangup_received] + [`dialog.hangup_received`][voip.sip.dialog.Dialog.hangup_received] so application code can perform teardown (e.g. closing the SIP transport for single-shot sessions). @@ -471,8 +466,8 @@ def answer(self, *, call_class: type[Session], **call_kwargs: typing.Any) -> Non """Answer the call by setting up RTP and sending 200 OK with SDP. Example: - Call from within [`Dialog.call_received`][voip.sip.messages.Dialog.call_received] - via [`Dialog.accept`][voip.sip.messages.Dialog.accept]: + Call from within [`Dialog.call_received`][voip.sip.dialog.Dialog.call_received] + via [`Dialog.accept`][voip.sip.dialog.Dialog.accept]: ```python class MyDialog(Dialog): @@ -619,7 +614,7 @@ async def make_call( and registers the RTP call handler. Prefer calling this indirectly via - [`Dialog.dial`][voip.sip.messages.Dialog.dial]. + [`Dialog.dial`][voip.sip.dialog.Dialog.dial]. Args: target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). @@ -860,7 +855,7 @@ class ByeTransaction(Transaction): ``` This transaction is created and awaited by - [`Dialog.bye`][voip.sip.messages.Dialog.bye]. + [`Dialog.bye`][voip.sip.dialog.Dialog.bye]. [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 """ @@ -870,11 +865,6 @@ class ByeTransaction(Transaction): def response_received(self, response: Response) -> None: """Handle the BYE response (typically 200 OK) [RFC 3261 §15.1.1]. - Removes this transaction from the SIP session once any final (2xx–6xx) - response is received. Provisional 1xx responses are silently ignored. - Sets [`Transaction.done`][voip.sip.transactions.Transaction.done] - so that anything awaiting this transaction can unblock. - Args: response: The parsed SIP response to our BYE request. """ From 5139dbf36586940f6fea5ac405954d0952c43f99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:28:09 +0000 Subject: [PATCH 12/45] Address review: Transaction inherits asyncio.Event, ByeTransaction builds its own request, route_set populated, trim docstrings Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/d085920a-59ce-4a29-9024-5e432cd56239 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- voip/__main__.py | 57 +------------------ voip/ai.py | 40 +------------ voip/sip/dialog.py | 117 +++++---------------------------------- voip/sip/transactions.py | 83 ++++++++++++++------------- 4 files changed, 64 insertions(+), 233 deletions(-) diff --git a/voip/__main__.py b/voip/__main__.py index 1adb2ea..2f1fffb 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -181,19 +181,6 @@ async def _connect_sip_once( use_tls: bool, no_verify_tls: bool, ) -> None: - """Connect to a SIP proxy exactly once and wait until the session ends. - - Unlike `_connect_sip`, this coroutine does not reconnect after the session - is closed. Use it when a single outbound call should end the process. - - Args: - session_factory: Callable that returns a new - [`SessionInitiationProtocol`][voip.sip.protocol.SessionInitiationProtocol] - instance. - proxy_addr: SIP proxy address as a `NetworkAddress`. - use_tls: Whether to establish a TLS connection. - no_verify_tls: When ``True``, skip TLS certificate verification. - """ loop = asyncio.get_running_loop() ssl_context: ssl.SSLContext | None = None if use_tls: @@ -219,25 +206,6 @@ def _make_outbound_factory( call_class: type, call_kwargs: dict, ) -> collections.abc.Callable[[], ConsoleMessageProtocol]: - """Build a single-shot protocol factory that dials TARGET after registration. - - Returns a factory suitable for `_connect_sip_once`. The factory creates a - [`ConsoleMessageProtocol`][voip.__main__.ConsoleMessageProtocol] subclass - that calls `on_registered` to initiate an outbound INVITE and closes the - SIP session when a BYE is received. - - Args: - verbose: Verbosity level forwarded to - [`ConsoleMessageProtocol`][voip.__main__.ConsoleMessageProtocol]. - aor: Local address-of-record. - rtp_protocol: Shared RTP mux. - target_uri: SIP URI to dial. - call_class: Call handler class (e.g. `EchoCall`). - call_kwargs: Extra keyword arguments forwarded to `call_class`. - - Returns: - A zero-argument factory returning a new protocol instance. - """ target = str(target_uri) class OutboundDialog(dialog.Dialog): @@ -268,19 +236,6 @@ def factory() -> ConsoleMessageProtocol: def _parse_dial_target(dial: str | None) -> SipUri | None: - """Parse and validate a ``--dial TARGET`` CLI value. - - Args: - dial: Raw string from the ``--dial`` option, or ``None`` when the - option is not supplied. - - Returns: - A parsed [`SipUri`][voip.sip.types.SipUri], or ``None`` when *dial* - is ``None``. - - Raises: - click.BadParameter: When *dial* is not a valid SIP URI. - """ if dial is None: return None try: @@ -298,11 +253,7 @@ def _parse_dial_target(dial: str | None) -> SipUri | None: ) @click.pass_context def echo(ctx, dial: str | None): - """Echo the caller's speech back after they finish speaking. - - Without ``--dial``, waits for inbound calls and echoes them. - With ``--dial TARGET``, registers and immediately dials TARGET. - """ + """Echo the caller's speech back after they finish speaking.""" from .audio import EchoCall # noqa: PLC0415 obj = ctx.obj @@ -368,11 +319,7 @@ async def run(): ) @click.pass_context def transcribe(ctx, stt_model, dial: str | None): - """Transcribe incoming call audio. - - Without ``--dial``, waits for inbound calls and transcribes them. - With ``--dial TARGET``, registers and immediately dials TARGET. - """ + """Transcribe incoming call audio.""" from faster_whisper import WhisperModel from .ai import TranscribeCall # noqa: PLC0415 diff --git a/voip/ai.py b/voip/ai.py index e7e0e0c..eec475d 100644 --- a/voip/ai.py +++ b/voip/ai.py @@ -139,38 +139,7 @@ async def send_speech(self, text: str) -> None: @dataclasses.dataclass(kw_only=True, slots=True) class SayCall(TTSMixin, AudioCall): - """Dial a number, say a message using TTS, and hang up. - - Synthesises `text` with Pocket TTS immediately after the call is - established, sends the audio as outbound RTP, then sends a SIP BYE - and closes the SIP session once the last packet has been dispatched. - - Example: - ```python - class MySession(SessionInitiationProtocol): - def on_registered(self) -> None: - tx = InviteTransaction(sip=self, method=SIPMethod.INVITE, cseq=1) - asyncio.create_task( - tx.make_call("sip:bob@biloxi.com", call_class=SayCall, text="Hello!") - ) - ``` - - To hang up programmatically from any call class, call - [`hang_up`][voip.rtp.Session.hang_up]: - - ```python - class MyCall(AudioCall): - async def voice_received(self, audio: np.ndarray) -> None: - await self.hang_up() - self.sip.close() - ``` - - Args: - text: The message to synthesise and transmit. - tts_model: Pre-loaded Pocket TTS model. A new default model is - loaded when omitted. - voice: Voice name or conditioning audio accepted by Pocket TTS. - """ + """Dial a number, say a message using TTS, and hang up.""" text: str @@ -179,16 +148,9 @@ def __post_init__(self) -> None: asyncio.create_task(self.send_speech(self.text)) def on_audio_sent(self) -> None: - """Send a SIP BYE and close the session after audio is fully dispatched.""" asyncio.create_task(self.hang_up()) async def hang_up(self) -> None: - """Send BYE and close the SIP transport. - - Extends the base [`hang_up`][voip.rtp.Session.hang_up] by also - closing the SIP transport after the BYE is acknowledged, terminating the - single-shot outbound call session. - """ await super().hang_up() if self.dialog is not None and self.dialog.sip is not None: self.dialog.sip.close() diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index 53d83c3..e1404fd 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -6,7 +6,6 @@ import typing import uuid -import voip from voip.sip import messages, transactions, types from voip.sip.types import SipUri @@ -15,17 +14,10 @@ @dataclasses.dataclass(kw_only=True, slots=True) class Dialog: - """ - Peer-to-peer SIP relationship between two user agents. - - A dialog is identified by the tuple of (Call-ID, From tag, To tag) and - established by a non-final response to the INVITE, see also: [RFC 3261 §12]. + """Peer-to-peer SIP relationship between two user agents [RFC 3261 §12]. - Subclass `Dialog` to implement inbound call handling. Override - [`call_received`][voip.sip.dialog.Dialog.call_received] and call - [`accept`][voip.sip.dialog.Dialog.accept] or - [`reject`][voip.sip.dialog.Dialog.reject] from within it. Register the - subclass as `dialog_class` on the SIP session: + Subclass `Dialog` to implement call handling. Set the subclass as + `dialog_class` on the SIP session for inbound calls: ```python class MyDialog(Dialog): @@ -37,8 +29,7 @@ class MySession(SessionInitiationProtocol): dialog_class = MyDialog ``` - For outbound calls, create a `Dialog` with the SIP session set and call - [`dial`][voip.sip.dialog.Dialog.dial]: + For outbound calls: ```python dialog = Dialog(sip=my_sip_session) @@ -46,36 +37,10 @@ class MySession(SessionInitiationProtocol): ``` [RFC 3261 §12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 - - Args: - uac: The user agent that initiated the dialog. - call_id: The Call-ID header value for this dialog. - local_tag: The From-header tag parameter value for this dialog. - remote_tag: The To-header tag parameter value for this dialog. - local_party: Raw ``From:`` header value (URI + tag) to use in - outbound in-dialog requests such as BYE. Populated by the - transaction layer when the dialog is confirmed. - remote_party: Raw ``To:`` header value (URI + tag) to use in - outbound in-dialog requests such as BYE. Populated by the - transaction layer when the dialog is confirmed. - outbound_cseq: CSeq sequence number for the *next* outbound - in-dialog request. Defaults to ``1`` for the UAS side - (no prior outbound request) and is set to ``cseq + 1`` on the - UAC side after the INVITE is confirmed. - sip: The SIP session that owns this dialog. Set by the transaction - layer when the dialog is confirmed. - invite_tx: The [`InviteTransaction`][voip.sip.transactions.InviteTransaction] - for an inbound INVITE. """ BYE_ACK_TIMEOUT: typing.ClassVar[float] = 32.0 - """Seconds to wait for a 200 OK from the remote party after sending BYE. - - Defaults to 64×T1 = 32 s — the standard non-INVITE transaction timeout - from [RFC 3261 §17.1.2]. The timeout lives on `Dialog` (rather than on - [`ByeTransaction`][voip.sip.transactions.ByeTransaction]) so that - application subclasses can configure it in one place alongside the other - call lifecycle hooks. Override in subclasses to change the timeout. + """Seconds to wait for a 200 OK after sending BYE (64×T1, [RFC 3261 §17.1.2]). [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 """ @@ -128,24 +93,20 @@ def headers(self) -> dict[str, str]: } def call_received(self) -> None: - """Handle an incoming INVITE. + """Called when an inbound INVITE arrives. - Called by the SIP layer after the dialog is created from the INVITE - request. The base implementation rejects the call with ``486 Busy - Here``. Override in subclasses to answer, ring, or reject the call - using [`accept`][voip.sip.dialog.Dialog.accept], + Override in subclasses to accept, ring, or reject the call using + [`accept`][voip.sip.dialog.Dialog.accept], [`ringing`][voip.sip.dialog.Dialog.ringing], and [`reject`][voip.sip.dialog.Dialog.reject]. + The base implementation rejects with 486 Busy Here. """ self.reject() def hangup_received(self) -> None: - """Handle an inbound BYE (remote party hanging up). + """Called when the remote party sends a BYE. - Called by the SIP layer after the 200 OK response has been sent for - the BYE. The base implementation is a no-op. Override in subclasses - to perform teardown, e.g. closing the SIP transport for single-shot - outbound sessions. + Override in subclasses to perform teardown. """ def ringing(self) -> None: @@ -157,10 +118,7 @@ def ringing(self) -> None: self.invite_tx.ringing() def accept(self, *, call_class: type, **call_kwargs: typing.Any) -> None: - """Accept the inbound call by answering with 200 OK and SDP. - - Delegates to - [`InviteTransaction.answer`][voip.sip.transactions.InviteTransaction.answer]. + """Accept the inbound call and answer with 200 OK. Args: call_class: Session subclass to create for this call. @@ -172,9 +130,6 @@ def accept(self, *, call_class: type, **call_kwargs: typing.Any) -> None: def reject(self, status_code: types.SIPStatus = types.SIPStatus.BUSY_HERE) -> None: """Reject the inbound call. - Delegates to - [`InviteTransaction.reject`][voip.sip.transactions.InviteTransaction.reject]. - Args: status_code: SIP response status code (default: 486 Busy Here). """ @@ -186,61 +141,19 @@ async def bye(self) -> None: [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 """ - if self.sip is None: - return - if self.local_party is None or self.remote_party is None: - logger.warning( - "Cannot BYE dialog %s: local or remote party not set", - self.call_id, - ) - return - if self.remote_contact is None: - logger.warning( - "Cannot BYE dialog %s: remote contact not known", - self.call_id, - ) - return - from voip.sip.transactions import ByeTransaction # noqa: PLC0415 from voip.sip.types import SIPMethod # noqa: PLC0415 - request_uri = str(self.remote_contact).strip("<>").split(";")[0] - tx = ByeTransaction( - sip=self.sip, - method=SIPMethod.BYE, - cseq=self.outbound_cseq, - dialog=self, - ) - bye_request = messages.Request( - method=SIPMethod.BYE, - uri=request_uri, - headers={ - "Via": ( - f"SIP/2.0/{self.sip.aor.transport}" - f' {self.sip.rtp.public_address};oc-algo="loss";oc;rport;branch={tx.branch}' - ), - "Max-Forwards": "70", - "From": self.local_party, - "To": self.remote_party, - "Call-ID": self.call_id, - "CSeq": f"{self.outbound_cseq} {SIPMethod.BYE}", - "Route": self.route_set[0] if self.route_set else None, - "User-Agent": f"python/vuoip/{voip.__version__}", - "Content-Length": "0", - }, - ) - self.sip.transactions[tx.branch] = tx - self.sip.send(bye_request) - self.outbound_cseq += 1 - self.sip.dialogs.pop((self.remote_tag, self.local_tag), None) + tx = ByeTransaction(sip=self.sip, method=SIPMethod.BYE, dialog=self) try: - await asyncio.wait_for(tx, timeout=self.BYE_ACK_TIMEOUT) + await asyncio.wait_for(tx.wait(), timeout=self.BYE_ACK_TIMEOUT) except TimeoutError: logger.warning( "BYE for dialog %s was not acknowledged within %.0f s", self.call_id, self.BYE_ACK_TIMEOUT, ) + self.sip.dialogs.pop((self.remote_tag, self.local_tag), None) async def dial( self, diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 06eff5a..2dcf8df 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -49,18 +49,17 @@ @dataclasses.dataclass(kw_only=True, slots=True) -class Transaction: +class Transaction(asyncio.Event): """ Initiated by a request, completed by any number of responses. Transactions are awaitable: ``await tx`` suspends until the transaction - reaches its terminal state (i.e. until ``tx.done`` is set). + reaches its terminal state. Args: dialog: The SIP dialog this transaction belongs to. branch: Unique identifier for the transaction, must start with "z9hG4bK". - cseq: The CSeq number for this transaction, starting at 1 and incremented - for each new request sent within the transaction. + cseq: The CSeq sequence number for this transaction. """ branch_prefix: typing.ClassVar[str] = "z9hG4bK" @@ -69,32 +68,26 @@ class Transaction: branch: str = dataclasses.field( default_factory=lambda: f"{Transaction.branch_prefix}-{uuid.uuid4()}" ) - cseq: int + cseq: int = 0 sip: SessionInitiationProtocol request: messages.Request | None = None responses: list[messages.Response] = dataclasses.field( init=False, default_factory=list ) dialog: Dialog = None - done: asyncio.Event = dataclasses.field( - init=False, - default_factory=asyncio.Event, - compare=False, - hash=False, - repr=False, - ) created: datetime.datetime = dataclasses.field( init=False, default_factory=datetime.datetime.now ) def __post_init__(self): + asyncio.Event.__init__(self) if not self.branch.startswith(self.branch_prefix): raise ValueError(f"Branch parameter must start with {self.branch_prefix!r}") def __await__(self) -> typing.Generator[typing.Any]: """Await the transaction reaching its terminal state.""" - yield from self.done.wait().__await__() + yield from self.wait().__await__() @property def headers(self) -> dict[str, str]: @@ -188,7 +181,7 @@ def response_received(self, response: Response) -> None: match response.status_code: case SIPStatus.OK: logger.info("Registration successful") - self.done.set() + self.set() self.sip.on_registered() return case SIPStatus.UNAUTHORIZED | SIPStatus.PROXY_AUTHENTICATION_REQUIRED: @@ -387,7 +380,7 @@ def ack_received(self, request: Request) -> None: request: The SIP ACK request. """ self.sip.transactions.pop(self.branch) - self.done.set() + self.set() def bye_received(self, request: Request) -> None: """Handle a BYE terminating a dialog. @@ -515,6 +508,7 @@ def call_received(self) -> None: dialog.sip = self.sip dialog.local_party = f"{self.request.headers['To']};tag={dialog.remote_tag}" dialog.remote_party = str(self.request.headers["From"]) + dialog.route_set = list(self.request.headers.getlist("Record-Route")) self.sip.dialogs[dialog.remote_tag, dialog.local_tag] = dialog call_handler = call_class( @@ -809,6 +803,10 @@ async def _accept_call(self, response: Response) -> None: self.dialog.remote_party = str(response.headers["To"]) self.dialog.remote_contact = ack_uri self.dialog.outbound_cseq = self.cseq + 1 + # RFC 3261 §12.1.2: UAC route set is Record-Route in reverse order. + self.dialog.route_set = list( + reversed(list(response.headers.getlist("Record-Route"))) + ) ack_headers: SIPHeaderDict = SIPHeaderDict( { "Via": ( @@ -823,8 +821,8 @@ async def _accept_call(self, response: Response) -> None: "Content-Length": 0, } ) - for record_route in response.headers.getlist("Record-Route"): - ack_headers.add("Route", record_route) + for route in self.dialog.route_set: + ack_headers.add("Route", route) self.sip.send( Request( method=SIPMethod.ACK, @@ -833,44 +831,55 @@ async def _accept_call(self, response: Response) -> None: ) ) self.sip.transactions.pop(self.branch, None) - self.done.set() + self.set() @dataclasses.dataclass(kw_only=True, slots=True) class ByeTransaction(Transaction): """BYE client transaction [RFC 3261 §17.1.2]. - Sends a BYE request to terminate an established dialog and waits for the - 200 OK acknowledgment from the remote party. Unlike INVITE, BYE responses - do **not** require an ACK — the 200 OK itself ends the transaction. - - Awaiting a `ByeTransaction` suspends until the remote party sends a final - (2xx+) response: - - ```python - tx = ByeTransaction(sip=sip, method=SIPMethod.BYE, cseq=2, dialog=dialog) - sip.transactions[tx.branch] = tx - sip.send(bye_request) - await asyncio.wait_for(tx, timeout=32.0) - ``` - - This transaction is created and awaited by - [`Dialog.bye`][voip.sip.dialog.Dialog.bye]. + Created by [`Dialog.bye`][voip.sip.dialog.Dialog.bye] to terminate a + dialog. The BYE request is built and sent immediately on construction. + Await the transaction to wait for the 200 OK acknowledgment. [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 """ - cseq: int = 1 + def __post_init__(self): + self.cseq = self.dialog.outbound_cseq + self.dialog.outbound_cseq += 1 + super().__post_init__() + request_uri = str(self.dialog.remote_contact).strip("<>").split(";")[0] + headers: SIPHeaderDict = SIPHeaderDict( + { + "Via": ( + f"SIP/2.0/{self.sip.aor.transport}" + f' {self.sip.rtp.public_address};oc-algo="loss";oc;rport;branch={self.branch}' + ), + "Max-Forwards": "70", + "From": self.dialog.local_party, + "To": self.dialog.remote_party, + "Call-ID": self.dialog.call_id, + "CSeq": f"{self.cseq} {SIPMethod.BYE}", + "User-Agent": f"python/voip/{voip.__version__}", + "Content-Length": "0", + } + ) + for route in self.dialog.route_set: + headers.add("Route", route) + self.request = Request(method=SIPMethod.BYE, uri=request_uri, headers=headers) + self.sip.transactions[self.branch] = self + self.sip.send(self.request) def response_received(self, response: Response) -> None: - """Handle the BYE response (typically 200 OK) [RFC 3261 §15.1.1]. + """Handle the BYE response [RFC 3261 §15.1.1]. Args: response: The parsed SIP response to our BYE request. """ if response.status_code >= 200: self.sip.transactions.pop(self.branch, None) - self.done.set() + self.set() logger.debug( "BYE acknowledged: %s %s", response.status_code, response.phrase ) From 727bdcf662972d8de256700cc63c8c9897d05da1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:31:11 +0000 Subject: [PATCH 13/45] Fix: ByeTransaction.method defaults to SIPMethod.BYE; remove method= from Dialog.bye() Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/d085920a-59ce-4a29-9024-5e432cd56239 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- voip/sip/dialog.py | 3 +-- voip/sip/transactions.py | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index e1404fd..c91baf7 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -142,9 +142,8 @@ async def bye(self) -> None: [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 """ from voip.sip.transactions import ByeTransaction # noqa: PLC0415 - from voip.sip.types import SIPMethod # noqa: PLC0415 - tx = ByeTransaction(sip=self.sip, method=SIPMethod.BYE, dialog=self) + tx = ByeTransaction(sip=self.sip, dialog=self) try: await asyncio.wait_for(tx.wait(), timeout=self.BYE_ACK_TIMEOUT) except TimeoutError: diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 2dcf8df..ab6cec0 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -845,6 +845,8 @@ class ByeTransaction(Transaction): [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 """ + method: SIPMethod = SIPMethod.BYE + def __post_init__(self): self.cseq = self.dialog.outbound_cseq self.dialog.outbound_cseq += 1 From 8dfc8c638c44e84eeb0767395cb4a3e48665e36d Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 1 Apr 2026 13:00:01 +0200 Subject: [PATCH 14/45] Update docs --- docs/cookbook.md | 67 ++++++++++++++++++++++------------------ docs/sessions.md | 6 ++-- voip/ai.py | 16 +++++----- voip/audio.py | 16 +++++----- voip/codecs/__init__.py | 12 +++---- voip/codecs/av.py | 14 ++++----- voip/codecs/base.py | 50 +++++++++++++++--------------- voip/codecs/g722.py | 18 +++++------ voip/codecs/opus.py | 2 +- voip/codecs/pcma.py | 2 +- voip/codecs/pcmu.py | 2 +- voip/rtp.py | 6 ++-- voip/sip/dialog.py | 10 +++--- voip/sip/protocol.py | 18 +++++------ voip/sip/transactions.py | 16 +++++----- 15 files changed, 131 insertions(+), 124 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index cbea5a5..b83b4b3 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1,7 +1,15 @@ +# Cookbook + +To build anything we need to understand two fundamental concepts: **Dialogs and [Sessions](sessions.md).** + +Dial, accept, reject, hold, transfer, etc. are part of a **dialog** between you and the remote party. + +The actual audio or video exchange happens in a multimedia **session**. A session is established by a dialog. + ## Call Transcription -Subclass \[`TranscribeCall`\][voip.ai.TranscribeCall] and override -\[`transcription_received`\][voip.ai.TranscribeCall.transcription_received] to +Subclass [TranscribeCall][voip.ai.TranscribeCall] and override +[transcription_received][voip.ai.TranscribeCall.transcription_received] to handle each utterance after a silence gap: ```python @@ -13,28 +21,27 @@ from voip.sip.dialog import Dialog from voip.sip.protocol import SIP -class MyCall(TranscribeCall): +class PrintTranscribeCall(TranscribeCall): + """Print the transcription to the console.""" + def transcription_received(self, text: str) -> None: print(f"[{self.caller}] {text}") -class MyDialog(Dialog): +class AutoAcceptDialog(Dialog): + """Accept every incoming call and transcribe it using MyCall.""" + def call_received(self) -> None: self.ringing() - self.accept(call_class=MyCall) - - -class MySession(SIP): - dialog_class = MyDialog + self.accept(call_class=PrintTranscribeCall) async def main(): loop = asyncio.get_running_loop() await loop.create_connection( - lambda: MySession( + lambda: SIP( aor="sips:alice@example.com", - username="alice", - password="secret", + dialog_class=AutoAcceptDialog, ), host="sip.example.com", port=5061, @@ -48,8 +55,8 @@ asyncio.run(main()) ## Sharing a Whisper Model Across Calls -Loading the model is expensive. Pass a pre-loaded -[`WhisperModel`](https://github.com/SYSTRAN/faster-whisper) instance as a +Loading the model is expensive. Pass a preloaded +[WhisperModel](https://github.com/SYSTRAN/faster-whisper) instance as a class attribute to share it across all incoming calls: ```python @@ -66,7 +73,7 @@ class MyCall(TranscribeCall): ## AI Call Agent -\[`AgentCall`\][voip.ai.AgentCall] extends transcription with an +[AgentCall][voip.ai.AgentCall] extends transcription with an [Ollama](https://ollama.com/) LLM response loop and [Pocket TTS](https://github.com/pocket-ai/pocket-tts) voice synthesis. Share both heavy models across calls to avoid reloading them per call: @@ -119,8 +126,8 @@ asyncio.run(main()) ## Raw Audio Access -Subclass \[`AudioCall`\][voip.audio.AudioCall] and override -\[`audio_received`\][voip.audio.AudioCall.audio_received] to receive decoded +Subclass [AudioCall][voip.audio.AudioCall] and override +[audio_received][voip.audio.AudioCall.audio_received] to receive decoded float32 PCM frames without transcription: ```python @@ -144,8 +151,8 @@ class RecordCall(AudioCall): ## Sending Audio to the Caller -Use \[`_send_rtp_audio`\][voip.audio.AudioCall.\_send_rtp_audio] inside any -\[`AudioCall`\][voip.audio.AudioCall] subclass to stream float32 PCM back to +Use [\_send_rtp_audio][voip.audio.AudioCall.\_send_rtp_audio] inside any +[AudioCall][voip.audio.AudioCall] subclass to stream float32 PCM back to the caller using the negotiated codec: ```python @@ -164,8 +171,8 @@ class GreetingCall(AudioCall): ## Low-Level RTP Packet Handling -For protocols other than audio, subclass \[`Session`\][voip.rtp.Session] -directly and override \[`packet_received`\]\[voip.rtp.Session.packet_received\]: +For protocols other than audio, subclass [Session][voip.rtp.Session] +directly and override [packet_received]\[voip.rtp.Session.packet_received\]: ```python from voip.rtp import Session, RTPPacket @@ -211,13 +218,13 @@ session = SIP( ## Hanging Up a Call -Every \[`Session`\][voip.rtp.Session] subclass exposes a -\[`hang_up`\][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE +Every [Session][voip.rtp.Session] subclass exposes a +[hang_up][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE request (RFC 3261 §15) by delegating to -\[`Dialog.bye`\][voip.sip.dialog.Dialog.bye]. It deregisters the RTP +[Dialog.bye][voip.sip.dialog.Dialog.bye]. It deregisters the RTP handler and awaits the 200 OK acknowledgment before returning. -Override \[`Dialog.call_received`\][voip.sip.dialog.Dialog.call_received] +Override [Dialog.call_received][voip.sip.dialog.Dialog.call_received] to hook into the call lifecycle, and call `await self.hang_up()` from within the call class when you want to terminate: @@ -270,18 +277,18 @@ async def main(): asyncio.run(main()) ``` -\[`hang_up`\][voip.rtp.Session.hang_up] sends the BYE and cleans up the dialog +[hang_up][voip.rtp.Session.hang_up] sends the BYE and cleans up the dialog and RTP handler — it does **not** close the SIP transport so that the same -\[`SIP`\][voip.sip.protocol.SessionInitiationProtocol] instance can continue +[SIP][voip.sip.protocol.SessionInitiationProtocol] instance can continue handling other calls. Access `self.dialog.sip.close()` when you also want to tear down the transport. ## Making Outbound Calls -Create a \[`Dialog`\][voip.sip.dialog.Dialog] subclass, set it as +Create a [Dialog][voip.sip.dialog.Dialog] subclass, set it as `dialog_class` on your SIP session, and call -\[`dialog.dial`\][voip.sip.dialog.Dialog.dial] from -\[`on_registered`\]\[voip.sip.protocol.SessionInitiationProtocol.on_registered\]: +[dial][voip.sip.dialog.Dialog.dial] from +[on_registered]\[voip.sip.protocol.SessionInitiationProtocol.on_registered\]: ```python import asyncio diff --git a/docs/sessions.md b/docs/sessions.md index b395a98..7b40de7 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -6,10 +6,10 @@ carries the SIP dialog state and provides the call lifecycle hooks. ## Dialog -The \[`Dialog`\][voip.sip.dialog.Dialog] class manages the SIP dialog state +The [Dialog][voip.sip.dialog.Dialog] class manages the SIP dialog state and is the primary extension point for application logic. Override -\[`call_received`\][voip.sip.dialog.Dialog.call_received] to accept or reject -inbound calls, and \[`hangup_received`\][voip.sip.dialog.Dialog.hangup_received] +[call_received][voip.sip.dialog.Dialog.call_received] to accept or reject +inbound calls, and [hangup_received][voip.sip.dialog.Dialog.hangup_received] to react when the remote party hangs up. ::: voip.sip.dialog.Dialog diff --git a/voip/ai.py b/voip/ai.py index eec475d..d8e7143 100644 --- a/voip/ai.py +++ b/voip/ai.py @@ -1,7 +1,7 @@ """AI-powered call handlers for RTP streams. -This module provides [`TranscribeCall`][voip.ai.TranscribeCall], which transcribes decoded audio -with faster-whisper, and [`AgentCall`][voip.ai.AgentCall], which extends it with an +This module provides [TranscribeCall][voip.ai.TranscribeCall], which transcribes decoded audio +with faster-whisper, and [AgentCall][voip.ai.AgentCall], which extends it with an Ollama-powered response loop and Pocket TTS voice synthesis. Requires the ``ai`` extra: ``pip install voip[ai]``. @@ -35,17 +35,17 @@ class TranscribeCall(VoiceActivityCall): """Transcribe incoming call audio. - Audio is decoded by [`AudioCall`][voip.audio.AudioCall] on a per-packet - basis and delivered to [`audio_received`][voip.audio.AudioCall.audio_received], + Audio is decoded by [AudioCall][voip.audio.AudioCall] on a per-packet + basis and delivered to [audio_received][voip.audio.AudioCall.audio_received], which applies an energy-based voice activity detector (VAD) from - [`VoiceActivityCall`][voip.audio.VoiceActivityCall]. All audio frames + [VoiceActivityCall][voip.audio.VoiceActivityCall]. All audio frames (speech and silence) are accumulated until silence is sustained for `silence_gap` seconds, then the entire utterance is sent to Whisper as one chunk. This avoids cutting sentences in the middle and prevents background microphone noise from being passed to Whisper as spurious audio. Example: - Override [`transcription_received`][voip.ai.TranscribeCall.transcription_received] + Override [transcription_received][voip.ai.TranscribeCall.transcription_received] to handle the resulting text: ```python @@ -101,8 +101,8 @@ class TTSMixin: """Mixin that adds Pocket TTS voice synthesis to a call. Provides shared `tts_model`, `voice`, and `voice_state` fields along with - the [`send_speech`][voip.ai.TTSMixin.send_speech] method used by both - [`SayCall`][voip.ai.SayCall] and [`AgentCall`][voip.ai.AgentCall]. + the [send_speech][voip.ai.TTSMixin.send_speech] method used by both + [SayCall][voip.ai.SayCall] and [AgentCall][voip.ai.AgentCall]. Args: tts_model: Pre-loaded Pocket TTS model. A new default model is loaded when omitted. diff --git a/voip/audio.py b/voip/audio.py index bb50fd8..8ddb6c1 100644 --- a/voip/audio.py +++ b/voip/audio.py @@ -1,12 +1,12 @@ """Audio call handler for RTP streams. -This module provides [`AudioCall`][voip.audio.AudioCall], which buffers RTP +This module provides [AudioCall][voip.audio.AudioCall], which buffers RTP packets, negotiates codecs, and decodes/encodes audio using the codec -implementations in [`voip.codecs`][voip.codecs]. +implementations in [voip.codecs][voip.codecs]. Requires the ``audio`` extra: ``pip install voip[audio]``. AI-powered subclasses (Whisper transcription, Ollama agent) live in -[`voip.ai`][voip.ai] and require the ``ai`` extra. +[voip.ai][voip.ai] and require the ``ai`` extra. """ import asyncio @@ -149,7 +149,7 @@ def sdp_formats(cls) -> list[RTPPayloadFormat]: can select the best available codec. Returns: - List of [`RTPPayloadFormat`][voip.sdp.types.RTPPayloadFormat] + List of [RTPPayloadFormat][voip.sdp.types.RTPPayloadFormat] objects for every codec in `supported_codecs`. """ return [codec.to_payload_format() for codec in cls.supported_codecs] @@ -213,7 +213,7 @@ def on_audio_sent(self) -> None: dispatched (i.e. `outbound_handle` transitions to ``None``). The base implementation is a no-op. Override in subclasses to trigger post-audio actions, for example hanging up after - [`SayCall`][voip.ai.SayCall] finishes speaking. + [SayCall][voip.ai.SayCall] finishes speaking. """ def _dispatch_next_packet( @@ -328,7 +328,7 @@ class VoiceActivityCall(AudioCall): AudioCall with energy-based Voice Activity Detection (VAD) and speech buffering. Full utterances are buffered and passed to - [`voice_received`][voip.audio.VoiceActivityCall.voice_received]. + [voice_received][voip.audio.VoiceActivityCall.voice_received]. Silent chunks are dropped from the audio stream. Override that method in subclasses to process complete speech segments @@ -342,11 +342,11 @@ class VoiceActivityCall(AudioCall): A full utterance must be separated from the previous one by at least the `silence_gap` to be considered complete and passed to - [`voice_received`][voip.audio.VoiceActivityCall.voice_received]. + [voice_received][voip.audio.VoiceActivityCall.voice_received]. Example: The following example shows how to use `VoiceActivityCall` to echo a caller's - voice back to them similar to [`EchoCall`][voip.audio.EchoCall]. + voice back to them similar to [EchoCall][voip.audio.EchoCall]. ```python import dataclasses diff --git a/voip/codecs/__init__.py b/voip/codecs/__init__.py index 9ae75e1..1665a08 100644 --- a/voip/codecs/__init__.py +++ b/voip/codecs/__init__.py @@ -1,14 +1,14 @@ """Audio codec implementations for RTP streams. -Provides the [`RTPCodec`][voip.codecs.base.RTPCodec] base class and concrete +Provides the [RTPCodec][voip.codecs.base.RTPCodec] base class and concrete implementations for all supported RTP audio codecs: -- [`PCMA`][voip.codecs.PCMA] — G.711 A-law (RFC 3551), PT 8 *(pure NumPy)* -- [`PCMU`][voip.codecs.PCMU] — G.711 mu-law (RFC 3551), PT 0 *(pure NumPy)* -- [`G722`][voip.codecs.G722] — G.722 (RFC 3551), PT 9 *(requires* ``pyav`` *extra)* -- [`Opus`][voip.codecs.Opus] — Opus (RFC 7587), PT 111 *(requires* ``pyav`` *extra)* +- [PCMA][voip.codecs.PCMA] — G.711 A-law (RFC 3551), PT 8 *(pure NumPy)* +- [PCMU][voip.codecs.PCMU] — G.711 mu-law (RFC 3551), PT 0 *(pure NumPy)* +- [G722][voip.codecs.G722] — G.722 (RFC 3551), PT 9 *(requires* ``pyav`` *extra)* +- [Opus][voip.codecs.Opus] — Opus (RFC 7587), PT 111 *(requires* ``pyav`` *extra)* -Use [`get`][voip.codecs.get] to look up a codec class by its SDP encoding +Use [get][voip.codecs.get] to look up a codec class by its SDP encoding name (case-insensitive). When the ``pyav`` extra is not installed only PCMA and PCMU are registered. diff --git a/voip/codecs/av.py b/voip/codecs/av.py index ba08fce..b0d75c9 100644 --- a/voip/codecs/av.py +++ b/voip/codecs/av.py @@ -1,14 +1,14 @@ """PyAV-backed RTP codec base class. -[`PyAVCodec`][voip.codecs.av.PyAVCodec] extends -[`RTPCodec`][voip.codecs.base.RTPCodec] with -[`decode_pcm`][voip.codecs.av.PyAVCodec.decode_pcm] and -[`encode_pcm`][voip.codecs.av.PyAVCodec.encode_pcm] helpers that use +[PyAVCodec][voip.codecs.av.PyAVCodec] extends +[RTPCodec][voip.codecs.base.RTPCodec] with +[decode_pcm][voip.codecs.av.PyAVCodec.decode_pcm] and +[encode_pcm][voip.codecs.av.PyAVCodec.encode_pcm] helpers that use [PyAV][] for container-aware decode and codec-aware encode. Requires the ``pyav`` extra: ``pip install voip[pyav]``. -Concrete subclasses: [`Opus`][voip.codecs.Opus], [`G722`][voip.codecs.G722]. +Concrete subclasses: [Opus][voip.codecs.Opus], [G722][voip.codecs.G722]. [PyAV]: https://pyav.basswood-io.com/ """ @@ -28,8 +28,8 @@ class PyAVCodec(RTPCodec): """RTP codec that decodes and encodes audio via [PyAV][]. - Concrete implementations: [`Opus`][voip.codecs.Opus], - [`G722`][voip.codecs.G722]. + Concrete implementations: [Opus][voip.codecs.Opus], + [G722][voip.codecs.G722]. [PyAV]: https://pyav.basswood-io.com/ """ diff --git a/voip/codecs/base.py b/voip/codecs/base.py index 0b358be..27a5d7d 100644 --- a/voip/codecs/base.py +++ b/voip/codecs/base.py @@ -1,14 +1,14 @@ """Base class for RTP audio codecs. All concrete codec classes in this package inherit from -[`RTPCodec`][voip.codecs.base.RTPCodec]. +[RTPCodec][voip.codecs.base.RTPCodec]. Codecs that require [PyAV][] for decode/encode additionally inherit from -[`PyAVCodec`][voip.codecs.av.PyAVCodec], which provides -[`decode_pcm`][voip.codecs.av.PyAVCodec.decode_pcm] and -[`encode_pcm`][voip.codecs.av.PyAVCodec.encode_pcm]. +[PyAVCodec][voip.codecs.av.PyAVCodec], which provides +[decode_pcm][voip.codecs.av.PyAVCodec.decode_pcm] and +[encode_pcm][voip.codecs.av.PyAVCodec.encode_pcm]. -Pure-NumPy codecs ([`PCMA`][voip.codecs.pcma.PCMA], [`PCMU`][voip.codecs.pcmu.PCMU]) +Pure-NumPy codecs ([PCMA][voip.codecs.pcma.PCMA], [PCMU][voip.codecs.pcmu.PCMU]) inherit directly from `RTPCodec` and require no PyAV dependency. [PyAV]: https://pyav.basswood-io.com/ @@ -29,9 +29,9 @@ class PayloadDecoder(Protocol): """Protocol for per-call RTP payload decoders. Implementations decode raw RTP payload bytes to float32 mono PCM. - Stateful implementations (e.g. [`G722Decoder`][voip.codecs.g722.G722Decoder]) + Stateful implementations (e.g. [G722Decoder][voip.codecs.g722.G722Decoder]) preserve codec predictor state across successive - [`decode`][voip.codecs.base.PayloadDecoder.decode] calls within a single + [decode][voip.codecs.base.PayloadDecoder.decode] calls within a single call session. """ @@ -50,24 +50,24 @@ def decode(self, payload: bytes) -> np.ndarray: class RTPCodec: """Base class for RTP audio codecs. - Concrete implementations: [`Opus`][voip.codecs.Opus], - [`G722`][voip.codecs.G722], [`PCMA`][voip.codecs.pcma.PCMA], - [`PCMU`][voip.codecs.pcmu.PCMU]. + Concrete implementations: [Opus][voip.codecs.Opus], + [G722][voip.codecs.G722], [PCMA][voip.codecs.pcma.PCMA], + [PCMU][voip.codecs.pcmu.PCMU]. Codec classes are stateless; every method is a classmethod or staticmethod and codecs are referenced as `type[RTPCodec]`, never instantiated. Per-call decoder state (required for ADPCM codecs such as G.722) is - managed by [`PayloadDecoder`][voip.codecs.base.PayloadDecoder] instances - returned by [`create_decoder`][voip.codecs.base.RTPCodec.create_decoder]. + managed by [PayloadDecoder][voip.codecs.base.PayloadDecoder] instances + returned by [create_decoder][voip.codecs.base.RTPCodec.create_decoder]. Concrete subclasses define codec-specific class variables and override - [`decode`][voip.codecs.base.RTPCodec.decode], - [`encode`][voip.codecs.base.RTPCodec.encode], and optionally - [`packetize`][voip.codecs.base.RTPCodec.packetize]. + [decode][voip.codecs.base.RTPCodec.decode], + [encode][voip.codecs.base.RTPCodec.encode], and optionally + [packetize][voip.codecs.base.RTPCodec.packetize]. Subclasses may use the shared PyAV-backed helpers or implement - [`decode`][voip.codecs.base.RTPCodec.decode] and - [`encode`][voip.codecs.base.RTPCodec.encode] using alternative backends + [decode][voip.codecs.base.RTPCodec.decode] and + [encode][voip.codecs.base.RTPCodec.encode] using alternative backends such as NumPy. Subclasses that produce variable-length output across frames (e.g. G.722 @@ -75,7 +75,7 @@ class RTPCodec: preserve predictor state. Subclasses that require [PyAV][] additionally inherit from - [`PyAVCodec`][voip.codecs.av.PyAVCodec]. + [PyAVCodec][voip.codecs.av.PyAVCodec]. [PyAV]: https://pyav.basswood-io.com/ """ @@ -107,7 +107,7 @@ def resample( ) -> np.ndarray: """Resample *audio* from *source_rate_hz* to *destination_rate_hz*. - Uses linear interpolation via [`numpy.interp`][]. + Uses linear interpolation via [numpy.interp][]. Args: audio: Float32 mono PCM array. @@ -131,7 +131,7 @@ def resample( @classmethod def to_payload_format(cls) -> RTPPayloadFormat: - """Create an [`RTPPayloadFormat`][voip.sdp.types.RTPPayloadFormat] for SDP negotiation. + """Create an [RTPPayloadFormat][voip.sdp.types.RTPPayloadFormat] for SDP negotiation. Uses `rtp_clock_rate_hz` as the SDP sample rate, which is correct per RFC 3551 (e.g. G.722 advertises 8000 Hz in SDP even though the @@ -178,7 +178,7 @@ def create_decoder( Override in subclasses that require stateful decoding across RTP packets (e.g. G.722 ADPCM — see - [`G722.create_decoder`][voip.codecs.g722.G722.create_decoder]). + [G722.create_decoder][voip.codecs.g722.G722.create_decoder]). Args: output_rate_hz: Target PCM sample rate in Hz for decoded audio. @@ -186,9 +186,9 @@ def create_decoder( codec default. Returns: - A [`PayloadDecoder`][voip.codecs.base.PayloadDecoder] that, by - default, is a [`PerPacketDecoder`][voip.codecs.base.PerPacketDecoder] - delegating each call to [`decode`][voip.codecs.base.RTPCodec.decode]. + A [PayloadDecoder][voip.codecs.base.PayloadDecoder] that, by + default, is a [PerPacketDecoder][voip.codecs.base.PerPacketDecoder] + delegating each call to [decode][voip.codecs.base.RTPCodec.decode]. """ return PerPacketDecoder(cls, output_rate_hz, input_rate_hz) @@ -229,7 +229,7 @@ class PerPacketDecoder: """Stateless payload decoder that processes each RTP packet independently. Delegate each call to - [`RTPCodec.decode`][voip.codecs.base.RTPCodec.decode], decoding each + [RTPCodec.decode][voip.codecs.base.RTPCodec.decode], decoding each payload independently without preserving cross-packet state. Suitable for stateless codecs such as PCMA, PCMU, and Opus. diff --git a/voip/codecs/g722.py b/voip/codecs/g722.py index af167ca..f75f547 100644 --- a/voip/codecs/g722.py +++ b/voip/codecs/g722.py @@ -1,10 +1,10 @@ """G.722 wideband codec implementation for RTP audio streams (RFC 3551). -The [`G722`][voip.codecs.g722.G722] class handles the RFC 3551 clock-rate +The [G722][voip.codecs.g722.G722] class handles the RFC 3551 clock-rate quirk: SDP advertises 8 000 Hz but the actual audio runs at 16 000 Hz. -Use [`G722Decoder`][voip.codecs.g722.G722Decoder] (via -[`G722.create_decoder`][voip.codecs.g722.G722.create_decoder]) for per-call +Use [G722Decoder][voip.codecs.g722.G722Decoder] (via +[G722.create_decoder][voip.codecs.g722.G722.create_decoder]) for per-call stateful decoding that preserves the ADPCM predictor state across consecutive RTP packets. @@ -83,15 +83,15 @@ def create_decoder( ) -> G722Decoder: """Create a stateful per-call G.722 decoder. - Returns a [`G722Decoder`][voip.codecs.g722.G722Decoder] that preserves + Returns a [G722Decoder][voip.codecs.g722.G722Decoder] that preserves the ADPCM predictor state across consecutive RTP packets. Pass the returned decoder to - [`AudioCall`][voip.audio.AudioCall] (via the `create_decoder` + [AudioCall][voip.audio.AudioCall] (via the `create_decoder` factory) to avoid the per-packet state reset that causes robotic audio artefacts. The *input_rate_hz* parameter is accepted for API consistency with - [`RTPCodec.create_decoder`][voip.codecs.base.RTPCodec.create_decoder] + [RTPCodec.create_decoder][voip.codecs.base.RTPCodec.create_decoder] but is not used; G.722 always decodes at 16 000 Hz internally. Args: @@ -99,7 +99,7 @@ def create_decoder( input_rate_hz: Ignored. G.722 always decodes at `sample_rate_hz`. Returns: - A new [`G722Decoder`][voip.codecs.g722.G722Decoder] instance. + A new [G722Decoder][voip.codecs.g722.G722Decoder] instance. """ return G722Decoder(output_rate_hz) @@ -109,13 +109,13 @@ class G722Decoder: """Stateful G.722 decoder that preserves ADPCM predictor state across packets. Creates a single persistent - [`av.CodecContext`](https://pyav.basswood-io.com/docs/stable/api/codec.html#av.codec.context.CodecContext) + [av.CodecContext](https://pyav.basswood-io.com/docs/stable/api/codec.html#av.codec.context.CodecContext) for the life of the decoder and feeds each incoming RTP packet to the same context. This eliminates the per-packet predictor reset that causes robotic artefacts when decoding a G.722 stream with independent codec contexts. - Use [`G722.create_decoder`][voip.codecs.g722.G722.create_decoder] rather + Use [G722.create_decoder][voip.codecs.g722.G722.create_decoder] rather than instantiating this class directly. Attributes: diff --git a/voip/codecs/opus.py b/voip/codecs/opus.py index a7b36c2..1f49657 100644 --- a/voip/codecs/opus.py +++ b/voip/codecs/opus.py @@ -1,6 +1,6 @@ """Opus codec implementation for RTP audio streams (RFC 7587). -The [`Opus`][voip.codecs.opus.Opus] class wraps raw Opus RTP payloads in a +The [Opus][voip.codecs.opus.Opus] class wraps raw Opus RTP payloads in a minimal [Ogg][] container before passing them to PyAV for decoding, and encodes float32 PCM via `libopus`. diff --git a/voip/codecs/pcma.py b/voip/codecs/pcma.py index 7c5750d..d27ff0c 100644 --- a/voip/codecs/pcma.py +++ b/voip/codecs/pcma.py @@ -1,6 +1,6 @@ """PCMA (G.711 A-law) codec implementation for RTP audio streams (RFC 3551). -The [`PCMA`][voip.codecs.pcma.PCMA] class decodes and encodes A-law RTP +The [PCMA][voip.codecs.pcma.PCMA] class decodes and encodes A-law RTP payloads using a pure-NumPy implementation of the ITU-T G.711 A-law segmented companding algorithm. No PyAV dependency is required. """ diff --git a/voip/codecs/pcmu.py b/voip/codecs/pcmu.py index 31adfc1..4010db1 100644 --- a/voip/codecs/pcmu.py +++ b/voip/codecs/pcmu.py @@ -1,6 +1,6 @@ """PCMU (G.711 mu-law) codec implementation for RTP audio streams (RFC 3551). -The [`PCMU`][voip.codecs.pcmu.PCMU] class decodes and encodes mu-law RTP +The [PCMU][voip.codecs.pcmu.PCMU] class decodes and encodes mu-law RTP payloads using a pure-NumPy implementation of ITU-T G.711 mu-law companding. No PyAV dependency is required. """ diff --git a/voip/rtp.py b/voip/rtp.py index ee75f59..9548ec5 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -141,7 +141,7 @@ async def hang_up(self) -> None: """Terminate the call by sending a SIP BYE request [RFC 3261 §15]. Deregisters this call from the RTP multiplexer, then delegates the - BYE signaling to [`Dialog.bye`][voip.sip.dialog.Dialog.bye], which + BYE signaling to [Dialog.bye][voip.sip.dialog.Dialog.bye], which constructs and sends the BYE request, removes the dialog from the SIP session's registry, and awaits the 200 OK acknowledgment. @@ -190,11 +190,11 @@ def sdp_formats(cls) -> list[RTPPayloadFormat]: """Return the list of supported payload formats for outbound SDP offers. Override in subclasses to advertise codec capabilities. - [`AudioCall`][voip.audio.AudioCall] overrides this to return all + [AudioCall][voip.audio.AudioCall] overrides this to return all supported codecs in priority order. Returns: - List of [`RTPPayloadFormat`][voip.sdp.types.RTPPayloadFormat] + List of [RTPPayloadFormat][voip.sdp.types.RTPPayloadFormat] objects describing the supported codecs. """ from voip.sdp.types import StaticPayloadType # noqa: PLC0415 diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index c91baf7..ad10358 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -96,18 +96,18 @@ def call_received(self) -> None: """Called when an inbound INVITE arrives. Override in subclasses to accept, ring, or reject the call using - [`accept`][voip.sip.dialog.Dialog.accept], - [`ringing`][voip.sip.dialog.Dialog.ringing], and - [`reject`][voip.sip.dialog.Dialog.reject]. + [accept][voip.sip.dialog.Dialog.accept], + [ringing][voip.sip.dialog.Dialog.ringing], and + [reject][voip.sip.dialog.Dialog.reject]. The base implementation rejects with 486 Busy Here. - """ + """ # noqa: D401 self.reject() def hangup_received(self) -> None: """Called when the remote party sends a BYE. Override in subclasses to perform teardown. - """ + """ # noqa: D401 def ringing(self) -> None: """Send a 180 Ringing provisional response [RFC 3261 §21.1.2]. diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 6feace0..7b40987 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -48,8 +48,8 @@ class SessionInitiationProtocol(asyncio.Protocol): authentication [RFC 3261 §22]. All signaling is sent over a single persistent TLS/TCP connection. - Subclass [`Dialog`][voip.sip.dialog.Dialog] and override - [`call_received`][voip.sip.dialog.Dialog.call_received] to handle + Subclass [Dialog][voip.sip.dialog.Dialog] and override + [call_received][voip.sip.dialog.Dialog.call_received] to handle inbound calls, then register it as `dialog_class`: ```python @@ -63,8 +63,8 @@ class MySession(SessionInitiationProtocol): ``` For outbound calls, use - [`Dialog.dial`][voip.sip.dialog.Dialog.dial] from within - [`on_registered`][voip.sip.protocol.SessionInitiationProtocol.on_registered]: + [Dialog.dial][voip.sip.dialog.Dialog.dial] from within + [on_registered][voip.sip.protocol.SessionInitiationProtocol.on_registered]: ```python class MySession(SessionInitiationProtocol): @@ -79,9 +79,9 @@ def on_registered(self) -> None: Args: aor: SIP Address of Record (AOR) to register with the carrier. rtp: Shared RTP mux for call media. - dialog_class: [`Dialog`][voip.sip.dialog.Dialog] subclass used to + dialog_class: [Dialog][voip.sip.dialog.Dialog] subclass used to create dialogs for incoming calls. Defaults to the base - [`Dialog`][voip.sip.dialog.Dialog] which rejects all calls with + [Dialog][voip.sip.dialog.Dialog] which rejects all calls with ``486 Busy Here``. registration_class: Transaction subclass to handle registration transactions. keepalive_interval: Keep-alive ping interval. Should be between 30 and 90 seconds. @@ -216,15 +216,15 @@ def allowed_methods(self) -> frozenset[SIPMethod]: """SIP methods supported by this UA. Always includes INVITE, ACK, BYE, CANCEL, and OPTIONS since - [`InviteTransaction`][voip.sip.transactions.InviteTransaction] handles + [InviteTransaction][voip.sip.transactions.InviteTransaction] handles all of these. OPTIONS is handled directly in - [`request_received`][voip.sip.protocol.SessionInitiationProtocol.request_received] + [request_received][voip.sip.protocol.SessionInitiationProtocol.request_received] without an ``options_received`` method, so it is added explicitly here. Additional methods (e.g. REGISTER) are included when the session defines a corresponding ``_received`` handler. Returns: - Frozenset of [`SIPMethod`][voip.sip.types.SIPMethod] values. + Frozenset of [SIPMethod][voip.sip.types.SIPMethod] values. """ core = frozenset( m for m in SIPMethod if hasattr(InviteTransaction, f"{m.lower()}_received") diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index ab6cec0..95aa9cb 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -337,8 +337,8 @@ class InviteTransaction(Transaction): SIP layer creates one instance per incoming INVITE, keyed by Via branch (RFC 3261 §17.1.3). - For inbound call handling, subclass [`Dialog`][voip.sip.dialog.Dialog] - and override [`call_received`][voip.sip.dialog.Dialog.call_received]: + For inbound call handling, subclass [Dialog][voip.sip.dialog.Dialog] + and override [call_received][voip.sip.dialog.Dialog.call_received]: ```python class MyDialog(Dialog): @@ -386,7 +386,7 @@ def bye_received(self, request: Request) -> None: """Handle a BYE terminating a dialog. Removes the dialog from the registry, sends a 200 OK, and calls - [`dialog.hangup_received`][voip.sip.dialog.Dialog.hangup_received] + [dialog.hangup_received][voip.sip.dialog.Dialog.hangup_received] so application code can perform teardown (e.g. closing the SIP transport for single-shot sessions). @@ -459,8 +459,8 @@ def answer(self, *, call_class: type[Session], **call_kwargs: typing.Any) -> Non """Answer the call by setting up RTP and sending 200 OK with SDP. Example: - Call from within [`Dialog.call_received`][voip.sip.dialog.Dialog.call_received] - via [`Dialog.accept`][voip.sip.dialog.Dialog.accept]: + Call from within [Dialog.call_received][voip.sip.dialog.Dialog.call_received] + via [Dialog.accept][voip.sip.dialog.Dialog.accept]: ```python class MyDialog(Dialog): @@ -608,7 +608,7 @@ async def make_call( and registers the RTP call handler. Prefer calling this indirectly via - [`Dialog.dial`][voip.sip.dialog.Dialog.dial]. + [Dialog.dial][voip.sip.dialog.Dialog.dial]. Args: target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). @@ -619,7 +619,7 @@ async def make_call( call class constructor. Returns: - The INVITE [`Request`][voip.sip.messages.Request] that was sent. + The INVITE [Request][voip.sip.messages.Request] that was sent. """ from .dialog import Dialog @@ -838,7 +838,7 @@ async def _accept_call(self, response: Response) -> None: class ByeTransaction(Transaction): """BYE client transaction [RFC 3261 §17.1.2]. - Created by [`Dialog.bye`][voip.sip.dialog.Dialog.bye] to terminate a + Created by [Dialog.bye][voip.sip.dialog.Dialog.bye] to terminate a dialog. The BYE request is built and sent immediately on construction. Await the transaction to wait for the 200 OK acknowledgment. From 282820b84486a23b1a0441f1420f92e3e7d9584f Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 1 Apr 2026 13:37:46 +0200 Subject: [PATCH 15/45] Update code --- docs/sessions.md | 10 ---------- docs/sip.md | 6 ------ voip/sip/__init__.py | 14 ++++++-------- voip/sip/dialog.py | 36 ++++++++++++++++++++++++------------ voip/sip/transactions.py | 15 +++++---------- 5 files changed, 35 insertions(+), 46 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index 7b40de7..15d2957 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -4,16 +4,6 @@ Each call leg is associated with a [Dialog][voip.sip.dialog.Dialog] that carries the SIP dialog state and provides the call lifecycle hooks. -## Dialog - -The [Dialog][voip.sip.dialog.Dialog] class manages the SIP dialog state -and is the primary extension point for application logic. Override -[call_received][voip.sip.dialog.Dialog.call_received] to accept or reject -inbound calls, and [hangup_received][voip.sip.dialog.Dialog.hangup_received] -to react when the remote party hangs up. - -::: voip.sip.dialog.Dialog - ## Audio Handling ::: voip.audio.AudioCall diff --git a/docs/sip.md b/docs/sip.md index d2565fa..6f1e7d6 100644 --- a/docs/sip.md +++ b/docs/sip.md @@ -1,9 +1,3 @@ # Session Initiation Protocol (SIP) ::: voip.sip - -## Transactions - -::: voip.sip.transactions.InviteTransaction - -::: voip.sip.transactions.RegistrationTransaction diff --git a/voip/sip/__init__.py b/voip/sip/__init__.py index 2a76e5b..9899f0c 100644 --- a/voip/sip/__init__.py +++ b/voip/sip/__init__.py @@ -4,21 +4,19 @@ [RFC 3261]: https://datatracker.ietf.org/doc/html/rfc3261 """ +from .dialog import Dialog from .messages import Message, Request, Response from .protocol import SessionInitiationProtocol -from .transactions import ByeTransaction, InviteTransaction, RegistrationTransaction from .types import CallerID, SIPMethod, SIPStatus, SipUri __all__ = [ - "Message", - "Request", - "Response", - "SessionInitiationProtocol", - "ByeTransaction", - "InviteTransaction", - "RegistrationTransaction", "CallerID", "SipUri", "SIPStatus", "SIPMethod", + "Message", + "Request", + "Response", + "Dialog", + "SessionInitiationProtocol", ] diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index ad10358..2d36308 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -9,6 +9,9 @@ from voip.sip import messages, transactions, types from voip.sip.types import SipUri +if typing.TYPE_CHECKING: + from voip.rtp import Session + logger = logging.getLogger("voip.sip") @@ -39,8 +42,15 @@ class MySession(SessionInitiationProtocol): [RFC 3261 §12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 """ - BYE_ACK_TIMEOUT: typing.ClassVar[float] = 32.0 - """Seconds to wait for a 200 OK after sending BYE (64×T1, [RFC 3261 §17.1.2]). + T1: typing.ClassVar[datetime.timedelta] = datetime.timedelta(milliseconds=500) + """ + Retransmission time according to [RFC 3261 §17.1.1]. + + [RFC 3261 §17.1.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.1 + """ + + BYE_ACK_TIMEOUT: typing.ClassVar[datetime.timedelta] = 64 * T1 + """Time to wait for a 200 OK after sending BYE (64×T1, [RFC 3261 §17.1.2]). [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 """ @@ -62,7 +72,7 @@ class MySession(SessionInitiationProtocol): sip: transactions.SessionInitiationProtocol | None = dataclasses.field( default=None, compare=False, repr=False ) - invite_tx: transactions.InviteTransaction | None = dataclasses.field( + invite_transaction: transactions.InviteTransaction | None = dataclasses.field( default=None, compare=False, repr=False ) @@ -114,8 +124,8 @@ def ringing(self) -> None: [RFC 3261 §21.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-21.1.2 """ - if self.invite_tx is not None: - self.invite_tx.ringing() + if self.invite_transaction is not None: + self.invite_transaction.ringing() def accept(self, *, call_class: type, **call_kwargs: typing.Any) -> None: """Accept the inbound call and answer with 200 OK. @@ -124,8 +134,8 @@ def accept(self, *, call_class: type, **call_kwargs: typing.Any) -> None: call_class: Session subclass to create for this call. **call_kwargs: Extra keyword arguments forwarded to `call_class`. """ - if self.invite_tx is not None: - self.invite_tx.answer(call_class=call_class, **call_kwargs) + if self.invite_transaction is not None: + self.invite_transaction.answer(call_class=call_class, **call_kwargs) def reject(self, status_code: types.SIPStatus = types.SIPStatus.BUSY_HERE) -> None: """Reject the inbound call. @@ -133,8 +143,8 @@ def reject(self, status_code: types.SIPStatus = types.SIPStatus.BUSY_HERE) -> No Args: status_code: SIP response status code (default: 486 Busy Here). """ - if self.invite_tx is not None: - self.invite_tx.reject(status_code) + if self.invite_transaction is not None: + self.invite_transaction.reject(status_code) async def bye(self) -> None: """Terminate the dialog by sending a SIP BYE request [RFC 3261 §15]. @@ -145,7 +155,9 @@ async def bye(self) -> None: tx = ByeTransaction(sip=self.sip, dialog=self) try: - await asyncio.wait_for(tx.wait(), timeout=self.BYE_ACK_TIMEOUT) + await asyncio.wait_for( + tx.wait(), timeout=self.BYE_ACK_TIMEOUT.total_seconds() + ) except TimeoutError: logger.warning( "BYE for dialog %s was not acknowledged within %.0f s", @@ -158,7 +170,7 @@ async def dial( self, target: str, *, - call_class: type, + call_class: type[Session], **call_kwargs: typing.Any, ) -> None: """Initiate an outbound call to *target* [RFC 3261 §13.1]. @@ -181,7 +193,7 @@ async def dial( cseq=1, dialog=self, ) - await tx.make_call(target, call_class=call_class, **call_kwargs) + await tx.make_call(target, dialog=self, call_class=call_class, **call_kwargs) @classmethod def from_request(cls, request: messages.Request) -> Dialog: diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 95aa9cb..8b9b704 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -366,7 +366,7 @@ def invite_received(self, request: Request) -> None: Args: request: The SIP INVITE request. """ - self.dialog.invite_tx = self + self.dialog.invite_transaction = self self.dialog.sip = self.sip self.dialog.call_received() @@ -597,7 +597,7 @@ async def make_call( target: str, *, call_class: type[Session], - dialog: Dialog | None = None, + dialog: Dialog, **call_kwargs: typing.Any, ) -> Request: """Initiate an outgoing call to `target`. @@ -621,18 +621,13 @@ async def make_call( Returns: The INVITE [Request][voip.sip.messages.Request] that was sent. """ - from .dialog import Dialog - self.pending_call_class = call_class self.pending_call_kwargs = call_kwargs target_uri = types.SipUri.parse(target) - if dialog is not None: - self.dialog = dialog - if self.dialog.uac is None: - self.dialog.uac = self.sip.aor - else: - self.dialog = Dialog(uac=self.sip.aor) + self.dialog = dialog + if self.dialog.uac is None: + self.dialog.uac = self.sip.aor self.dialog.sip = self.sip rtp_public = self.sip.rtp.public_address From 7e38b870cbd45229faa0e501564ea357ddef374c Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 1 Apr 2026 16:54:56 +0200 Subject: [PATCH 16/45] Update docs --- .pre-commit-config.yaml | 1 + docs/cookbook.md | 2 +- docs/sdp.md | 4 ---- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 06f5104..fb7ac2d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,6 +36,7 @@ repos: - mdformat-footnote - mdformat-gfm - mdformat-gfm-alerts + - mdformat-mkdocs - mdformat-ruff exclude: ^\.github/agents/.*\.agent\.md$ - repo: https://github.com/astral-sh/ruff-pre-commit diff --git a/docs/cookbook.md b/docs/cookbook.md index b83b4b3..47c3034 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1,6 +1,6 @@ # Cookbook -To build anything we need to understand two fundamental concepts: **Dialogs and [Sessions](sessions.md).** +To build anything we need to understand two fundamental concepts: **[Dialogs][voip.sip.Dialog] and [Sessions](sessions.md).** Dial, accept, reject, hold, transfer, etc. are part of a **dialog** between you and the remote party. diff --git a/docs/sdp.md b/docs/sdp.md index 158e011..628fce6 100644 --- a/docs/sdp.md +++ b/docs/sdp.md @@ -1,7 +1,3 @@ # Session Description Protocol (SDP) ::: voip.sdp - -## Types - -::: voip.sdp.types From 14b780e804a3757f7432f06bb6c3f7e7fc5fcfcd Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 1 Apr 2026 19:16:25 +0200 Subject: [PATCH 17/45] Rename --- README.md | 2 +- docs/cookbook.md | 8 ++++---- voip/__main__.py | 8 ++++---- voip/sip/dialog.py | 27 ++++++++++++++++---------- voip/sip/protocol.py | 2 -- voip/sip/transactions.py | 41 ++++++++++++++++++++++------------------ 6 files changed, 49 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index ff0859c..98e4577 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ class TranscribeInviteTransaction(InviteTransaction): def invite_received(self, request) -> None: self.ringing() self.answer( - call_class=TranscribingCall, + session_class=TranscribingCall, stt_model=WhisperModel("kyutai/stt-1b-en_fr-trfs", device="cuda"), ) diff --git a/docs/cookbook.md b/docs/cookbook.md index 47c3034..595b51f 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -33,7 +33,7 @@ class AutoAcceptDialog(Dialog): def call_received(self) -> None: self.ringing() - self.accept(call_class=PrintTranscribeCall) + self.accept(session_class=PrintTranscribeCall) async def main(): @@ -101,7 +101,7 @@ class MyCall(AgentCall): class MyDialog(Dialog): def call_received(self) -> None: self.ringing() - self.accept(call_class=MyCall) + self.accept(session_class=MyCall) class MySession(SIP): @@ -252,7 +252,7 @@ class OneUtteranceCall(AudioCall): class MyDialog(Dialog): def call_received(self) -> None: self.ringing() - self.accept(call_class=OneUtteranceCall) + self.accept(session_class=OneUtteranceCall) class MySession(SIP): @@ -316,7 +316,7 @@ class MySession(SIP): def on_registered(self) -> None: dialog = OutboundDialog(sip=self) asyncio.create_task( - dialog.dial("sip:+15551234567@carrier.com", call_class=MyCall) + dialog.dial("sip:+15551234567@carrier.com", session_class=MyCall) ) diff --git a/voip/__main__.py b/voip/__main__.py index 2f1fffb..eab2092 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -220,7 +220,7 @@ class OutboundProtocol(ConsoleMessageProtocol): def on_registered(self) -> None: dialog = OutboundDialog(sip=self) asyncio.create_task( - dialog.dial(self.dial_target, call_class=call_class, **call_kwargs) + dialog.dial(self.dial_target, session_class=call_class, **call_kwargs) ) def factory() -> ConsoleMessageProtocol: @@ -263,7 +263,7 @@ def echo(ctx, dial: str | None): class EchoDialog(dialog.Dialog): def call_received(self) -> None: self.ringing() - self.accept(call_class=EchoCall) + self.accept(session_class=EchoCall) async def run(): _, rtp_protocol = await _connect_rtp( @@ -339,7 +339,7 @@ class TranscribeDialog(dialog.Dialog): def call_received(self) -> None: self.ringing() self.accept( - call_class=TranscribingCall, + session_class=TranscribingCall, stt_model=WhisperModel(stt_model), ) @@ -470,7 +470,7 @@ class AgentDialog(dialog.Dialog): def call_received(self) -> None: self.ringing() self.accept( - call_class=AgentCallWithOutput, + session_class=AgentCallWithOutput, stt_model=WhisperModel(stt_model), llm_model=llm_model, voice=voice, diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index 2d36308..a6c2288 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -127,15 +127,19 @@ def ringing(self) -> None: if self.invite_transaction is not None: self.invite_transaction.ringing() - def accept(self, *, call_class: type, **call_kwargs: typing.Any) -> None: + def accept( + self, *, session_class: type[Session], **session_kwargs: typing.Any + ) -> None: """Accept the inbound call and answer with 200 OK. Args: - call_class: Session subclass to create for this call. - **call_kwargs: Extra keyword arguments forwarded to `call_class`. + session_class: Session subclass to create for this call. + **session_kwargs: Extra keyword arguments forwarded to `call_class`. """ if self.invite_transaction is not None: - self.invite_transaction.answer(call_class=call_class, **call_kwargs) + self.invite_transaction.answer( + session_class=session_class, **session_kwargs + ) def reject(self, status_code: types.SIPStatus = types.SIPStatus.BUSY_HERE) -> None: """Reject the inbound call. @@ -170,15 +174,15 @@ async def dial( self, target: str, *, - call_class: type[Session], - **call_kwargs: typing.Any, + session_class: type[Session], + **session_kwargs: typing.Any, ) -> None: """Initiate an outbound call to *target* [RFC 3261 §13.1]. Args: target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). - call_class: Session subclass to create for this call. - **call_kwargs: Extra keyword arguments forwarded to `call_class`. + session_class: Session subclass to create for this call. + **session_kwargs: Extra keyword arguments forwarded to `call_class`. [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 """ @@ -193,14 +197,17 @@ async def dial( cseq=1, dialog=self, ) - await tx.make_call(target, dialog=self, call_class=call_class, **call_kwargs) + await tx.make_call( + target, dialog=self, session_class=session_class, **session_kwargs + ) @classmethod - def from_request(cls, request: messages.Request) -> Dialog: + def from_request(cls, request: messages.Request, **kwargs) -> Dialog: """Create a dialog from a request, extracting relevant headers.""" return cls( call_id=request.headers["Call-ID"], local_tag=request.local_tag, remote_tag=request.remote_tag or str(uuid.uuid4()), remote_contact=request.headers.get("Contact"), + **kwargs, ) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 7b40987..3607f00 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -83,7 +83,6 @@ def on_registered(self) -> None: create dialogs for incoming calls. Defaults to the base [Dialog][voip.sip.dialog.Dialog] which rejects all calls with ``486 Busy Here``. - registration_class: Transaction subclass to handle registration transactions. keepalive_interval: Keep-alive ping interval. Should be between 30 and 90 seconds. """ @@ -91,7 +90,6 @@ def on_registered(self) -> None: aor: types.SipUri rtp: RealtimeTransportProtocol dialog_class: type[Dialog] = dataclasses.field(default=Dialog) - registration_class: type[RegistrationTransaction] = RegistrationTransaction keepalive_interval: datetime.timedelta = datetime.timedelta(seconds=30) keepalive_task: asyncio.Task | None = dataclasses.field(init=False, default=None) diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 8b9b704..a864f65 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -455,7 +455,9 @@ def reject(self, status_code: SIPStatus = SIPStatus.BUSY_HERE) -> None: ) ) - def answer(self, *, call_class: type[Session], **call_kwargs: typing.Any) -> None: + def answer( + self, *, session_class: type[Session], **session_kwargs: typing.Any + ) -> None: """Answer the call by setting up RTP and sending 200 OK with SDP. Example: @@ -469,8 +471,8 @@ def call_received(self) -> None: ``` Args: - call_class: Session implementation that will be initialized. - **call_kwargs: Additional keyword arguments forwarded to the + session_class: Session implementation that will be initialized. + **session_kwargs: Additional keyword arguments forwarded to the call class constructor. Raises: @@ -492,7 +494,7 @@ def call_received(self) -> None: None, ) if remote_audio is not None: - negotiated_media = call_class.negotiate_codec(remote_audio) + negotiated_media = session_class.negotiate_codec(remote_audio) else: negotiated_media = MediaDescription( media="audio", @@ -503,21 +505,24 @@ def call_received(self) -> None: use_srtp = negotiated_media.proto == "RTP/SAVP" srtp_session = SRTPSession.generate() if use_srtp else None + from .dialog import Dialog - dialog = Dialog.from_request(self.request) - dialog.sip = self.sip - dialog.local_party = f"{self.request.headers['To']};tag={dialog.remote_tag}" - dialog.remote_party = str(self.request.headers["From"]) - dialog.route_set = list(self.request.headers.getlist("Record-Route")) + dialog = Dialog.from_request( + self.request, + sip=self.sip, + local_party=f"{self.request.headers['To']};tag={self.request.remote_tag}", + remote_party=str(self.request.headers["From"]), + route_set=list(self.request.headers.getlist("Record-Route")), + ) self.sip.dialogs[dialog.remote_tag, dialog.local_tag] = dialog - call_handler = call_class( + call_handler = session_class( rtp=self.sip.rtp, caller=caller, media=negotiated_media, srtp=srtp_session, dialog=dialog, - **call_kwargs, + **session_kwargs, ) if remote_audio is not None and remote_audio.port != 0: media_connection = remote_audio.connection @@ -596,9 +601,9 @@ async def make_call( self, target: str, *, - call_class: type[Session], dialog: Dialog, - **call_kwargs: typing.Any, + session_class: type[Session], + **session_kwargs: typing.Any, ) -> Request: """Initiate an outgoing call to `target`. @@ -612,17 +617,17 @@ async def make_call( Args: target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). - call_class: Session implementation that will be initialized for the call. + session_class: Session implementation that will be initialized for the call. dialog: Existing dialog to use. When ``None`` a new dialog is created from the SIP session's AOR. - **call_kwargs: Additional keyword arguments forwarded to the + **session_kwargs: Additional keyword arguments forwarded to the call class constructor. Returns: The INVITE [Request][voip.sip.messages.Request] that was sent. """ - self.pending_call_class = call_class - self.pending_call_kwargs = call_kwargs + self.pending_call_class = session_class + self.pending_call_kwargs = session_kwargs target_uri = types.SipUri.parse(target) self.dialog = dialog @@ -656,7 +661,7 @@ async def make_call( media="audio", port=rtp_public[1], proto="RTP/AVP", - fmt=call_class.sdp_formats(), + fmt=session_class.sdp_formats(), attributes=[Attribute(name="sendrecv")], ) ], From 35110bfac0d0a565713d759d432fdffdca666e8d Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 1 Apr 2026 19:17:43 +0200 Subject: [PATCH 18/45] Move import --- voip/sip/transactions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index a864f65..089257e 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -479,6 +479,8 @@ def call_received(self) -> None: NotImplementedError: When `negotiate_codec` raises (no supported codec in the remote SDP offer). """ + from .dialog import Dialog + peer = ( self.sip.transport.get_extra_info("peername") if self.sip.transport @@ -505,7 +507,6 @@ def call_received(self) -> None: use_srtp = negotiated_media.proto == "RTP/SAVP" srtp_session = SRTPSession.generate() if use_srtp else None - from .dialog import Dialog dialog = Dialog.from_request( self.request, From c74bd3965b89378b3278b73d6799d75e03d84d32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:47:32 +0000 Subject: [PATCH 19/45] =?UTF-8?q?Refactor:=20Transaction=E2=86=92Future,?= =?UTF-8?q?=20symmetric=20send/receive,=20fix=20routing,=20decouple=20on?= =?UTF-8?q?=5Fregistered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/bc83a95a-a304-4385-a304-788e49597c08 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- voip/sip/dialog.py | 17 +-- voip/sip/protocol.py | 66 +++++----- voip/sip/transactions.py | 273 ++++++++++++++++++++++++--------------- 3 files changed, 211 insertions(+), 145 deletions(-) diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index a6c2288..e61df88 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -157,10 +157,10 @@ async def bye(self) -> None: """ from voip.sip.transactions import ByeTransaction # noqa: PLC0415 - tx = ByeTransaction(sip=self.sip, dialog=self) try: await asyncio.wait_for( - tx.wait(), timeout=self.BYE_ACK_TIMEOUT.total_seconds() + ByeTransaction.send(sip=self.sip, dialog=self), + timeout=self.BYE_ACK_TIMEOUT.total_seconds(), ) except TimeoutError: logger.warning( @@ -187,18 +187,13 @@ async def dial( [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 """ from voip.sip.transactions import InviteTransaction # noqa: PLC0415 - from voip.sip.types import SIPMethod # noqa: PLC0415 - if self.uac is None and self.sip is not None: - self.uac = self.sip.aor - tx = InviteTransaction( + await InviteTransaction.send( sip=self.sip, - method=SIPMethod.INVITE, - cseq=1, + target=target, dialog=self, - ) - await tx.make_call( - target, dialog=self, session_class=session_class, **session_kwargs + session_class=session_class, + **session_kwargs, ) @classmethod diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 3607f00..8452339 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -17,7 +17,7 @@ from . import types from .dialog import Dialog from .messages import Message, Request, Response -from .transactions import InviteTransaction, RegistrationTransaction, Transaction +from .transactions import ByeTransaction, InviteTransaction, RegistrationTransaction, Transaction from .types import ( SIPMethod, SIPStatus, @@ -119,6 +119,7 @@ def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore loop = asyncio.get_running_loop() tx = RegistrationTransaction(sip=self, method=SIPMethod.REGISTER) self.transactions[tx.branch] = tx + loop.create_task(self.handle_registration(tx)) self.keepalive_task = loop.create_task(self.send_keepalive()) except RuntimeError: pass # no running loop in synchronous test setups @@ -131,6 +132,11 @@ async def send_keepalive(self) -> None: logger.info("PING", extra={"addr": self.local_address}) self.transport.write(PING) + async def handle_registration(self, tx: RegistrationTransaction) -> None: + """Await carrier registration and invoke [on_registered][voip.sip.protocol.SessionInitiationProtocol.on_registered].""" + await tx + self.on_registered() + def data_received(self, data: bytes) -> None: self.recv_buffer.extend(data) for frame in self._extract_frames(): @@ -211,28 +217,16 @@ def close(self) -> None: @property def allowed_methods(self) -> frozenset[SIPMethod]: - """SIP methods supported by this UA. - - Always includes INVITE, ACK, BYE, CANCEL, and OPTIONS since - [InviteTransaction][voip.sip.transactions.InviteTransaction] handles - all of these. OPTIONS is handled directly in - [request_received][voip.sip.protocol.SessionInitiationProtocol.request_received] - without an ``options_received`` method, so it is added explicitly here. - Additional methods (e.g. REGISTER) are included when the session - defines a corresponding ``_received`` handler. - - Returns: - Frozenset of [SIPMethod][voip.sip.types.SIPMethod] values. - """ - core = frozenset( - m for m in SIPMethod if hasattr(InviteTransaction, f"{m.lower()}_received") - ) - extra = frozenset( - m for m in SIPMethod if hasattr(self, f"{m.lower()}_received") + """SIP methods supported by this UA.""" + return frozenset( + { + SIPMethod.INVITE, + SIPMethod.ACK, + SIPMethod.BYE, + SIPMethod.CANCEL, + SIPMethod.OPTIONS, + } ) - # OPTIONS is handled inline in request_received() without a dedicated - # handler method, so we add it to the allowed set explicitly. - return core | extra | frozenset([SIPMethod.OPTIONS]) @property def allow_header(self) -> str: @@ -262,8 +256,22 @@ def method_not_allowed(self, request: Request) -> None: ) def request_received(self, request: Request) -> None: - """Dispatch request to transaction methods.""" + """Dispatch an incoming SIP request to the appropriate transaction.""" match request.method: + case SIPMethod.INVITE: + asyncio.create_task( + InviteTransaction.receive(request=request, sip=self) + ) + case SIPMethod.ACK: + try: + dialog = self.dialogs[request.remote_tag, request.local_tag] + dialog.invite_transaction.ack_received(request) + except (KeyError, AttributeError): + logger.warning("ACK for unknown dialog: %r", request) + case SIPMethod.BYE: + asyncio.create_task( + ByeTransaction.receive(request=request, sip=self) + ) case SIPMethod.CANCEL: try: tx = self.transactions[request.branch] @@ -276,6 +284,7 @@ def request_received(self, request: Request) -> None: ) ) return + tx.cancel_received(request) case SIPMethod.OPTIONS: self.send( Response.from_request( @@ -285,17 +294,8 @@ def request_received(self, request: Request) -> None: headers={"Allow": self.allow_header}, ) ) - return case _: - tx = InviteTransaction.from_request(request=request, sip=self) - self.transactions[request.branch] = tx - try: - handler: typing.Callable[[Request], Response | None] = getattr( - tx, f"{request.method.lower()}_received" - ) - except AttributeError: - handler = self.method_not_allowed - handler(request) + self.method_not_allowed(request) def response_received(self, response: Response) -> None: """Delegate REGISTER responses to the registration transaction. diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 089257e..b778e51 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -49,12 +49,12 @@ @dataclasses.dataclass(kw_only=True, slots=True) -class Transaction(asyncio.Event): +class Transaction(asyncio.Future): """ Initiated by a request, completed by any number of responses. Transactions are awaitable: ``await tx`` suspends until the transaction - reaches its terminal state. + reaches its terminal state and resolves to the dialog. Args: dialog: The SIP dialog this transaction belongs to. @@ -68,7 +68,7 @@ class Transaction(asyncio.Event): branch: str = dataclasses.field( default_factory=lambda: f"{Transaction.branch_prefix}-{uuid.uuid4()}" ) - cseq: int = 0 + cseq: int sip: SessionInitiationProtocol request: messages.Request | None = None responses: list[messages.Response] = dataclasses.field( @@ -81,14 +81,10 @@ class Transaction(asyncio.Event): ) def __post_init__(self): - asyncio.Event.__init__(self) + asyncio.Future.__init__(self) if not self.branch.startswith(self.branch_prefix): raise ValueError(f"Branch parameter must start with {self.branch_prefix!r}") - def __await__(self) -> typing.Generator[typing.Any]: - """Await the transaction reaching its terminal state.""" - yield from self.wait().__await__() - @property def headers(self) -> dict[str, str]: """Return a dict of headers for this transaction.""" @@ -104,6 +100,11 @@ def send_response(self, response: messages.Response): """Send a response to this transaction.""" self.sip.send(response) + def complete(self) -> None: + """Resolve the transaction with its dialog if not already complete.""" + if not self.done(): + self.set_result(self.dialog) + @classmethod def from_request( cls, @@ -181,8 +182,7 @@ def response_received(self, response: Response) -> None: match response.status_code: case SIPStatus.OK: logger.info("Registration successful") - self.set() - self.sip.on_registered() + self.set_result(self.dialog) return case SIPStatus.UNAUTHORIZED | SIPStatus.PROXY_AUTHENTICATION_REQUIRED: logger.debug( @@ -246,11 +246,22 @@ def response_received(self, response: Response) -> None: authorization=auth_value, ) self.sip.transactions[tx.branch] = tx + tx.add_done_callback(self.forward_result) case _: raise NotImplementedError( f"Unknown SIP status code: {response.status_code}" ) + def forward_result(self, fut: asyncio.Future) -> None: + """Forward the result of *fut* to this transaction (used for auth retry chaining).""" + if not self.done(): + if fut.cancelled(): + self.cancel() + elif exc := fut.exception(): + self.set_exception(exc) + else: + self.set_result(fut.result()) + @staticmethod def parse_auth_challenge(header: str) -> dict[str, str]: """Parse Digest challenge parameters from a WWW-Authenticate/Proxy-Authenticate header. @@ -344,7 +355,7 @@ class InviteTransaction(Transaction): class MyDialog(Dialog): def call_received(self) -> None: self.ringing() - self.accept(call_class=MyCall) + self.accept(session_class=MyCall) class MySession(SessionInitiationProtocol): dialog_class = MyDialog @@ -360,58 +371,52 @@ class MySession(SessionInitiationProtocol): default_factory=dict, repr=False ) - def invite_received(self, request: Request) -> None: - """Handle an incoming INVITE by delegating to the dialog. + @classmethod + async def receive( + cls, + *, + request: Request, + sip: SessionInitiationProtocol, + ) -> Dialog: + """Handle an incoming INVITE [RFC 3261 §13.3]. + + Registers the transaction, notifies the dialog, and resolves when the + ACK is received. Args: - request: The SIP INVITE request. + request: The incoming SIP INVITE request. + sip: The SIP session receiving the request. + + Returns: + The dialog once the call is established (ACK received). + + [RFC 3261 §13.3]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.3 """ - self.dialog.invite_transaction = self - self.dialog.sip = self.sip - self.dialog.call_received() + tx = cls.from_request(request=request, sip=sip) + sip.transactions[request.branch] = tx + tx.dialog.invite_transaction = tx + tx.dialog.sip = sip + tx.dialog.call_received() + return await tx def ack_received(self, request: Request) -> None: """Handle an ACK confirming dialog establishment (RFC 3261 §17.2.1). - Removes the INVITE server transaction from the registry and marks the - transaction as done. + Removes the INVITE server transaction from the registry and resolves + the transaction future with the dialog. Args: request: The SIP ACK request. """ - self.sip.transactions.pop(self.branch) - self.set() - - def bye_received(self, request: Request) -> None: - """Handle a BYE terminating a dialog. - - Removes the dialog from the registry, sends a 200 OK, and calls - [dialog.hangup_received][voip.sip.dialog.Dialog.hangup_received] - so application code can perform teardown (e.g. closing the SIP - transport for single-shot sessions). - - Args: - request: The SIP BYE request. - """ - self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag)) - self.send_response( - Response.from_request( - request, - dialog=self.dialog, - status_code=SIPStatus.OK, - phrase=SIPStatus.OK.phrase, - ) - ) - self.dialog.hangup_received() - - def cancel_received(self, request: Request) -> None: + self.sip.transactions.pop(self.branch, None) + self.complete() """Handle a CANCEL request for a pending INVITE. Args: request: The SIP CANCEL request. """ - self.sip.transactions.pop(self.branch) - self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag)) + self.sip.transactions.pop(self.branch, None) + self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag), None) self.send_response( Response.from_request( request, @@ -420,6 +425,8 @@ def cancel_received(self, request: Request) -> None: phrase=SIPStatus.OK.phrase, ) ) + if not self.done(): + self.cancel() def ringing(self) -> None: """Send a 180 Ringing provisional response [RFC 3261 §21.1.2]. @@ -598,45 +605,46 @@ def call_received(self) -> None: ) ) - async def make_call( - self, - target: str, + @classmethod + async def send( + cls, *, + sip: SessionInitiationProtocol, + target: str, dialog: Dialog, session_class: type[Session], **session_kwargs: typing.Any, - ) -> Request: - """Initiate an outgoing call to `target`. - - Builds an SDP offer using `call_class.sdp_formats`, sends an INVITE, - and registers this transaction to handle the response. When the callee - answers (200 OK), `_accept_call` completes the setup, sends the ACK, - and registers the RTP call handler. - - Prefer calling this indirectly via - [Dialog.dial][voip.sip.dialog.Dialog.dial]. + ) -> Dialog: + """Initiate an outgoing call to *target* [RFC 3261 §13.1]. Args: + sip: The SIP session to send from. target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). + dialog: The dialog to associate with this call. session_class: Session implementation that will be initialized for the call. - dialog: Existing dialog to use. When ``None`` a new dialog is - created from the SIP session's AOR. **session_kwargs: Additional keyword arguments forwarded to the call class constructor. Returns: - The INVITE [Request][voip.sip.messages.Request] that was sent. + The dialog once the call is established (ACK sent). + + [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 """ - self.pending_call_class = session_class - self.pending_call_kwargs = session_kwargs + if dialog.uac is None: + dialog.uac = sip.aor + dialog.sip = sip target_uri = types.SipUri.parse(target) - self.dialog = dialog - if self.dialog.uac is None: - self.dialog.uac = self.sip.aor - self.dialog.sip = self.sip + tx = cls( + sip=sip, + method=SIPMethod.INVITE, + cseq=dialog.outbound_cseq, + dialog=dialog, + ) + tx.pending_call_class = session_class + tx.pending_call_kwargs = session_kwargs - rtp_public = self.sip.rtp.public_address + rtp_public = sip.rtp.public_address session_id = str(secrets.randbelow(2**32) + 1) sdp_offer = SessionDescription( origin=Origin( @@ -667,26 +675,30 @@ async def make_call( ) ], ) - self.request = Request( + tx.request = Request( method=SIPMethod.INVITE, uri=target_uri, headers={ "Max-Forwards": "70", - **self.headers, - "From": self.dialog.from_header, + **tx.headers, + "From": dialog.from_header, "To": str(target_uri), - "Contact": self.sip.contact, - "Call-ID": self.dialog.call_id, + "Contact": sip.contact, + "Call-ID": dialog.call_id, "Route": f"", - "Allow": self.sip.allow_header, + "Allow": sip.allow_header, "User-Agent": f"python/voip/{voip.__version__}", "Content-Type": "application/sdp", }, body=sdp_offer, ) - self.sip.transactions[self.branch] = self - self.sip.send(self.request) - return self.request + sip.transactions[tx.branch] = tx + sip.send(tx.request) + try: + return await tx + except asyncio.CancelledError: + sip.transactions.pop(tx.branch, None) + raise def response_received(self, response: Response) -> None: """Handle responses to an outbound INVITE. @@ -832,57 +844,116 @@ async def _accept_call(self, response: Response) -> None: ) ) self.sip.transactions.pop(self.branch, None) - self.set() + self.complete() @dataclasses.dataclass(kw_only=True, slots=True) class ByeTransaction(Transaction): - """BYE client transaction [RFC 3261 §17.1.2]. + """BYE transaction for terminating a dialog [RFC 3261 §15, §17.1.2]. - Created by [Dialog.bye][voip.sip.dialog.Dialog.bye] to terminate a - dialog. The BYE request is built and sent immediately on construction. - Await the transaction to wait for the 200 OK acknowledgment. + Use [send][voip.sip.transactions.ByeTransaction.send] to terminate a + dialog from the local side, or + [receive][voip.sip.transactions.ByeTransaction.receive] to handle a + BYE sent by the remote party. + [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 """ method: SIPMethod = SIPMethod.BYE - def __post_init__(self): - self.cseq = self.dialog.outbound_cseq - self.dialog.outbound_cseq += 1 - super().__post_init__() - request_uri = str(self.dialog.remote_contact).strip("<>").split(";")[0] + @classmethod + async def send( + cls, + *, + sip: SessionInitiationProtocol, + dialog: Dialog, + ) -> Dialog: + """Send a BYE request and wait for the 200 OK [RFC 3261 §15.1.1]. + + Args: + sip: The SIP session to send from. + dialog: The dialog to terminate. + + Returns: + The dialog once the BYE is acknowledged. + + [RFC 3261 §15.1.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-15.1.1 + """ + cseq = dialog.outbound_cseq + dialog.outbound_cseq += 1 + tx = cls(sip=sip, dialog=dialog, cseq=cseq) + request_uri = str(dialog.remote_contact).strip("<>").split(";")[0] headers: SIPHeaderDict = SIPHeaderDict( { "Via": ( - f"SIP/2.0/{self.sip.aor.transport}" - f' {self.sip.rtp.public_address};oc-algo="loss";oc;rport;branch={self.branch}' + f"SIP/2.0/{sip.aor.transport}" + f' {sip.rtp.public_address};oc-algo="loss";oc;rport;branch={tx.branch}' ), "Max-Forwards": "70", - "From": self.dialog.local_party, - "To": self.dialog.remote_party, - "Call-ID": self.dialog.call_id, - "CSeq": f"{self.cseq} {SIPMethod.BYE}", + "From": dialog.local_party, + "To": dialog.remote_party, + "Call-ID": dialog.call_id, + "CSeq": f"{cseq} {SIPMethod.BYE}", "User-Agent": f"python/voip/{voip.__version__}", "Content-Length": "0", } ) - for route in self.dialog.route_set: + for route in dialog.route_set: headers.add("Route", route) - self.request = Request(method=SIPMethod.BYE, uri=request_uri, headers=headers) - self.sip.transactions[self.branch] = self - self.sip.send(self.request) + tx.request = Request(method=SIPMethod.BYE, uri=request_uri, headers=headers) + sip.transactions[tx.branch] = tx + sip.send(tx.request) + try: + return await tx + except asyncio.CancelledError: + sip.transactions.pop(tx.branch, None) + raise + + @classmethod + async def receive( + cls, + *, + request: Request, + sip: SessionInitiationProtocol, + ) -> Dialog: + """Handle an incoming BYE from the remote party [RFC 3261 §15.1.2]. + + Sends 200 OK, removes the dialog, and notifies the application via + [hangup_received][voip.sip.dialog.Dialog.hangup_received]. + + Args: + request: The incoming SIP BYE request. + sip: The SIP session receiving the request. + + Returns: + The terminated dialog. + + [RFC 3261 §15.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-15.1.2 + """ + tx = cls.from_request(request=request, sip=sip) + sip.dialogs.pop((tx.dialog.remote_tag, tx.dialog.local_tag), None) + tx.send_response( + Response.from_request( + request, + dialog=tx.dialog, + status_code=SIPStatus.OK, + phrase=SIPStatus.OK.phrase, + ) + ) + tx.dialog.hangup_received() + tx.set_result(tx.dialog) + return await tx def response_received(self, response: Response) -> None: - """Handle the BYE response [RFC 3261 §15.1.1]. + """Handle the 200 OK for an outgoing BYE [RFC 3261 §15.1.1]. Args: response: The parsed SIP response to our BYE request. """ if response.status_code >= 200: self.sip.transactions.pop(self.branch, None) - self.set() + self.complete() logger.debug( "BYE acknowledged: %s %s", response.status_code, response.phrase ) From 22fb921328cea8a8d1add483e2097ac941ffdb02 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 1 Apr 2026 19:19:22 +0200 Subject: [PATCH 20/45] Fix docs --- docs/sdp.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sdp.md b/docs/sdp.md index 628fce6..158e011 100644 --- a/docs/sdp.md +++ b/docs/sdp.md @@ -1,3 +1,7 @@ # Session Description Protocol (SDP) ::: voip.sdp + +## Types + +::: voip.sdp.types From 08169c0029a17d393c0cf25ebda9c7d75babf480 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:57:36 +0000 Subject: [PATCH 21/45] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- voip/sip/protocol.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 8452339..01bb3bc 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -17,7 +17,12 @@ from . import types from .dialog import Dialog from .messages import Message, Request, Response -from .transactions import ByeTransaction, InviteTransaction, RegistrationTransaction, Transaction +from .transactions import ( + ByeTransaction, + InviteTransaction, + RegistrationTransaction, + Transaction, +) from .types import ( SIPMethod, SIPStatus, @@ -266,12 +271,10 @@ def request_received(self, request: Request) -> None: try: dialog = self.dialogs[request.remote_tag, request.local_tag] dialog.invite_transaction.ack_received(request) - except (KeyError, AttributeError): + except KeyError, AttributeError: logger.warning("ACK for unknown dialog: %r", request) case SIPMethod.BYE: - asyncio.create_task( - ByeTransaction.receive(request=request, sip=self) - ) + asyncio.create_task(ByeTransaction.receive(request=request, sip=self)) case SIPMethod.CANCEL: try: tx = self.transactions[request.branch] From 10f011b1b47cce50ce4f8ade86bc3cfb828e053a Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 2 Apr 2026 19:49:47 +0200 Subject: [PATCH 22/45] Cleanup --- .pre-commit-config.yaml | 2 +- CONTRIBUTING.md | 8 +- README.md | 2 +- docs/contributing.md | 1 + docs/cookbook.md | 6 +- docs/rtp.md | 11 -- docs/sdp.md | 7 - docs/sessions.md | 11 +- docs/sip.md | 24 ++- mkdocs.yml | 17 +- pyproject.toml | 2 +- voip/__main__.py | 32 ++-- voip/ai.py | 4 +- voip/audio.py | 2 +- voip/rtp.py | 9 +- voip/sip/dialog.py | 72 ++++---- voip/sip/protocol.py | 62 +++---- voip/sip/transactions.py | 23 ++- voip/sip/types.py | 376 +++++++++++++++------------------------ 19 files changed, 311 insertions(+), 360 deletions(-) create mode 120000 docs/contributing.md delete mode 100644 docs/rtp.md delete mode 100644 docs/sdp.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb7ac2d..687494a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,7 +36,7 @@ repos: - mdformat-footnote - mdformat-gfm - mdformat-gfm-alerts - - mdformat-mkdocs + - mdformat-mkdocs>=5.2.0b1 - mdformat-ruff exclude: ^\.github/agents/.*\.agent\.md$ - repo: https://github.com/astral-sh/ruff-pre-commit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2338831..9af57c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,16 +16,16 @@ uv run pytest Avoid mocking in your tests and instead use real dependencies to ensure that your tests are as close to real-world scenarios as possible. You may only mock transports to avoid network IO or to mimic network counterparts. -Before your first commit, ensure that the pre-commit hooks are installed by running: +## Testing with Extra Dependencies ```bash -uvx prek install +uv run --extra=cli --extra=pygments --extra=audio pytest ``` -## Testing with Extra Dependencies +Before your first commit, ensure that the pre-commit hooks are installed by running: ```bash -uv run --extra=cli --extra=pygments --extra=audio pytest +uvx prek install ``` ## Writing documentation diff --git a/README.md b/README.md index 98e4577..3e0c0f0 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ uv add voip[audio,ai,pygments] ``` Subclass `TranscribeCall` and override `transcription_received` to handle results. -Pass it as `call_class` when answering an incoming call: +Pass it as `session_class` when answering an incoming call: ```python import asyncio diff --git a/docs/contributing.md b/docs/contributing.md new file mode 120000 index 0000000..44fcc63 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/cookbook.md b/docs/cookbook.md index 595b51f..3f61a30 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -33,7 +33,7 @@ class AutoAcceptDialog(Dialog): def call_received(self) -> None: self.ringing() - self.accept(session_class=PrintTranscribeCall) + self.answer(session_class=PrintTranscribeCall) async def main(): @@ -101,7 +101,7 @@ class MyCall(AgentCall): class MyDialog(Dialog): def call_received(self) -> None: self.ringing() - self.accept(session_class=MyCall) + self.answer(session_class=MyCall) class MySession(SIP): @@ -252,7 +252,7 @@ class OneUtteranceCall(AudioCall): class MyDialog(Dialog): def call_received(self) -> None: self.ringing() - self.accept(session_class=OneUtteranceCall) + self.answer(session_class=OneUtteranceCall) class MySession(SIP): diff --git a/docs/rtp.md b/docs/rtp.md deleted file mode 100644 index ec3f42e..0000000 --- a/docs/rtp.md +++ /dev/null @@ -1,11 +0,0 @@ -# Real-time Transport Protocol (RTP) - -::: voip.rtp - -## Encryption - -::: voip.srtp - -## NAT Traversal - -::: voip.stun diff --git a/docs/sdp.md b/docs/sdp.md deleted file mode 100644 index 158e011..0000000 --- a/docs/sdp.md +++ /dev/null @@ -1,7 +0,0 @@ -# Session Description Protocol (SDP) - -::: voip.sdp - -## Types - -::: voip.sdp.types diff --git a/docs/sessions.md b/docs/sessions.md index 15d2957..3737499 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -1,8 +1,11 @@ -# Multimedia Sessions / Call Leg Handlers +# Multimedia Sessions -[Session][voip.rtp.Session] is the base class for all call leg handlers. -Each call leg is associated with a [Dialog][voip.sip.dialog.Dialog] that -carries the SIP dialog state and provides the call lifecycle hooks. +[Session][voip.rtp.Session] and its subclasses handle the media exchange between call parties. +They are created by the [Dialog][voip.sip.dialog.Dialog] when a call is accepted or initiated. + +Sessions can be audio, video, and more. However, this library currently only provides audio sessions via the [AudioCall][voip.audio.AudioCall] class. Video and other media types are fairly uncommon outside of consumer applications, and implementing them is on the roadmap but not yet a priority. + +::: voip.rtp.Session ## Audio Handling diff --git a/docs/sip.md b/docs/sip.md index 6f1e7d6..4c15506 100644 --- a/docs/sip.md +++ b/docs/sip.md @@ -1,3 +1,25 @@ # Session Initiation Protocol (SIP) -::: voip.sip +::: voip.sip.Dialog + options: + heading_level: 2 + members: + - call_received + - hangup_received + - ringing + - accept + - reject + - dial + +::: voip.sip.SessionInitiationProtocol + options: + heading_level: 2 + members: false + +## Types + +::: voip.sip.SipUri + +::: voip.sip.CallerID + +::: voip.sip.SIPStatus diff --git a/mkdocs.yml b/mkdocs.yml index 13b2a5f..37c16eb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,22 +4,21 @@ watch: - docs - voip nav: - - Home: + - Usage: - Quickstart: index.md - Cookbook: cookbook.md + - Sessions: sessions.md + - SIP: sip.md + - Codecs: codecs.md - Features: - Feature Roadmap: feature_roadmap.md + - Contributing: contributing.md - RFC Implementation Status: rfc_status.md - - API Reference: - - Sessions: sessions.md - - Codecs: codecs.md - - RTP: rtp.md - - SDP: sdp.md - - SIP: sip.md - Changelog: https://github.com/codingjoe/VoIP/releases - Community Support: https://github.com/codingjoe/VoIP/discussions plugins: - - autorefs + - autorefs: + resolve_closest: true - search - mkdocstrings: default_handler: python @@ -27,12 +26,14 @@ plugins: python: load_external_modules: true options: + locale: en docstring_style: google show_root_heading: true heading_level: 3 inventories: - https://docs.python.org/3/objects.inv - https://numpy.org/doc/stable/objects.inv + - https://pyav.org/docs/stable/objects.inv theme: name: material logo: images/icon.svg diff --git a/pyproject.toml b/pyproject.toml index 1e09017..c79b380 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ write_to = "voip/_version.py" [tool.pytest.ini_options] minversion = "6.0" -addopts = "--cov --strict-markers --cov-report=xml --cov-report=term" +addopts = "--cov --strict-markers --cov-report=xml --cov-report=term --doctest-modules" asyncio_mode = "auto" testpaths = ["tests"] markers = [ diff --git a/voip/__main__.py b/voip/__main__.py index eab2092..7ba9372 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -9,7 +9,7 @@ import time from voip.ai import SayCall -from voip.rtp import RealtimeTransportProtocol +from voip.rtp import RealtimeTransportProtocol, Session from voip.sip import dialog, messages from voip.sip.protocol import SessionInitiationProtocol from voip.sip.types import SipUri @@ -203,8 +203,8 @@ def _make_outbound_factory( aor: SipUri, rtp_protocol: RealtimeTransportProtocol, target_uri: SipUri, - call_class: type, - call_kwargs: dict, + session_class: type[Session], + session_kwargs: dict, ) -> collections.abc.Callable[[], ConsoleMessageProtocol]: target = str(target_uri) @@ -220,7 +220,9 @@ class OutboundProtocol(ConsoleMessageProtocol): def on_registered(self) -> None: dialog = OutboundDialog(sip=self) asyncio.create_task( - dialog.dial(self.dial_target, session_class=call_class, **call_kwargs) + dialog.dial( + self.dial_target, session_class=session_class, **session_kwargs + ) ) def factory() -> ConsoleMessageProtocol: @@ -263,7 +265,7 @@ def echo(ctx, dial: str | None): class EchoDialog(dialog.Dialog): def call_received(self) -> None: self.ringing() - self.accept(session_class=EchoCall) + self.answer(session_class=EchoCall) async def run(): _, rtp_protocol = await _connect_rtp( @@ -289,8 +291,8 @@ async def run(): aor=aor, rtp_protocol=rtp_protocol, target_uri=target_uri, - call_class=EchoCall, - call_kwargs={}, + session_class=EchoCall, + session_kwargs={}, ), aor.maddr, aor.transport == "TLS", @@ -338,7 +340,7 @@ def transcription_received(self, text: str) -> None: class TranscribeDialog(dialog.Dialog): def call_received(self) -> None: self.ringing() - self.accept( + self.answer( session_class=TranscribingCall, stt_model=WhisperModel(stt_model), ) @@ -367,8 +369,8 @@ async def run(): aor=aor, rtp_protocol=rtp_protocol, target_uri=target_uri, - call_class=TranscribingCall, - call_kwargs={"stt_model": WhisperModel(stt_model)}, + session_class=TranscribingCall, + session_kwargs={"stt_model": WhisperModel(stt_model)}, ), aor.maddr, aor.transport == "TLS", @@ -469,7 +471,7 @@ async def respond(self) -> None: class AgentDialog(dialog.Dialog): def call_received(self) -> None: self.ringing() - self.accept( + self.answer( session_class=AgentCallWithOutput, stt_model=WhisperModel(stt_model), llm_model=llm_model, @@ -502,8 +504,8 @@ async def run(): aor=aor, rtp_protocol=rtp_protocol, target_uri=target_uri, - call_class=AgentCallWithOutput, - call_kwargs={ + session_class=AgentCallWithOutput, + session_kwargs={ "stt_model": WhisperModel(stt_model), "llm_model": llm_model, "voice": voice, @@ -554,8 +556,8 @@ async def run(): aor=aor, rtp_protocol=rtp_protocol, target_uri=target_uri, - call_class=SayCall, - call_kwargs={"text": prompt, "voice": voice}, + session_class=SayCall, + session_kwargs={"text": prompt, "voice": voice}, ), aor.maddr, aor.transport == "TLS", diff --git a/voip/ai.py b/voip/ai.py index d8e7143..b0857de 100644 --- a/voip/ai.py +++ b/voip/ai.py @@ -51,11 +51,11 @@ class TranscribeCall(VoiceActivityCall): ```python class MySession(SessionInitiationProtocol): def call_received(self, request: Request) -> None: - self.answer(request=request, call_class=MyCall) + self.answer(request=request, session_class=MyCall) ``` To share one model instance across multiple calls (recommended to avoid - loading it multiple times) pass a pre-loaded `WhisperModel`: + loading it multiple times) pass a preloaded `WhisperModel`: ```python shared_model = WhisperModel("base") diff --git a/voip/audio.py b/voip/audio.py index 8ddb6c1..4fb4bd3 100644 --- a/voip/audio.py +++ b/voip/audio.py @@ -440,7 +440,7 @@ class EchoCall(VoiceActivityCall): ```python class MySession(SessionInitiationProtocol): def call_received(self, request: Request) -> None: - self.answer(request=request, call_class=EchoCall) + self.answer(request=request, session_class=EchoCall) ``` """ diff --git a/voip/rtp.py b/voip/rtp.py index 9548ec5..b43074f 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -103,17 +103,17 @@ class Session: Attributes: rtp: Shared RTP multiplexer socket that delivers packets to this handler. - caller: Caller identifier as received in the SIP From header. + dialog: SIP dialog state for this call leg. media: Negotiated SDP media description for this call leg. + caller: Caller identifier as received in the SIP From header. srtp: Optional SRTP session for encrypting and decrypting media. - dialog: SIP dialog state for this call leg. """ rtp: RealtimeTransportProtocol + dialog: Dialog media: MediaDescription caller: CallerID srtp: SRTPSession | None = None - dialog: Dialog | None = None def packet_received(self, packet: RTPPacket, addr: NetworkAddress) -> None: """Handle a parsed RTP packet. Override in subclasses to process media. @@ -138,7 +138,8 @@ def send_packet(self, packet: RTPPacket, addr: NetworkAddress) -> None: self.rtp.send(data, addr) async def hang_up(self) -> None: - """Terminate the call by sending a SIP BYE request [RFC 3261 §15]. + """ + Terminate the call by sending a SIP BYE request [RFC 3261 §15]. Deregisters this call from the RTP multiplexer, then delegates the BYE signaling to [Dialog.bye][voip.sip.dialog.Dialog.bye], which diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index e61df88..41cac28 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -26,7 +26,7 @@ class Dialog: class MyDialog(Dialog): def call_received(self) -> None: self.ringing() - self.accept(call_class=MyCall) + self.accept(session_class=MyCall) class MySession(SessionInitiationProtocol): dialog_class = MyDialog @@ -36,26 +36,23 @@ class MySession(SessionInitiationProtocol): ```python dialog = Dialog(sip=my_sip_session) - await dialog.dial("sip:bob@biloxi.com", call_class=MyCall) + await dialog.dial("sip:bob@biloxi.com", session_class=MyCall) ``` + [RFC 3261 §12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 - """ - T1: typing.ClassVar[datetime.timedelta] = datetime.timedelta(milliseconds=500) + Args: + sip: The parent protocol the session belongs to. """ - Retransmission time according to [RFC 3261 §17.1.1]. - [RFC 3261 §17.1.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.1 - """ + # https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.1 + T1: typing.ClassVar[datetime.timedelta] = datetime.timedelta(milliseconds=500) + # https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 BYE_ACK_TIMEOUT: typing.ClassVar[datetime.timedelta] = 64 * T1 - """Time to wait for a 200 OK after sending BYE (64×T1, [RFC 3261 §17.1.2]). - [RFC 3261 §17.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 - """ - - uac: SipUri | None = None + uac: SipUri = None call_id: str = dataclasses.field( default_factory=lambda: f"{uuid.uuid4()}@{socket.gethostname()}", compare=False, @@ -103,38 +100,43 @@ def headers(self) -> dict[str, str]: } def call_received(self) -> None: - """Called when an inbound INVITE arrives. + """ + Called when an INVITE is received from the remote party. + + Override in subclasses to [accept][voip.sip.dialog.Dialog.accept], + [ring][voip.sip.dialog.Dialog.ringing], + or [reject][voip.sip.dialog.Dialog.reject] the call. - Override in subclasses to accept, ring, or reject the call using - [accept][voip.sip.dialog.Dialog.accept], - [ringing][voip.sip.dialog.Dialog.ringing], and - [reject][voip.sip.dialog.Dialog.reject]. - The base implementation rejects with 486 Busy Here. + The base implementation rejects with a busy signal. """ # noqa: D401 self.reject() def hangup_received(self) -> None: - """Called when the remote party sends a BYE. + """ + Called when the remote party sends a BYE. Override in subclasses to perform teardown. """ # noqa: D401 def ringing(self) -> None: - """Send a 180 Ringing provisional response [RFC 3261 §21.1.2]. + """ + Send the report party a ringing signal. - [RFC 3261 §21.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-21.1.2 + This is optional but recommended for good user experience. + If not called, the caller will hear silence until the call is accepted or rejected. """ if self.invite_transaction is not None: self.invite_transaction.ringing() - def accept( + def answer( self, *, session_class: type[Session], **session_kwargs: typing.Any ) -> None: - """Accept the inbound call and answer with 200 OK. + """ + Accept the inbound call and start a multimedia session. Args: session_class: Session subclass to create for this call. - **session_kwargs: Extra keyword arguments forwarded to `call_class`. + **session_kwargs: Extra keyword arguments forwarded to `session_class`. """ if self.invite_transaction is not None: self.invite_transaction.answer( @@ -142,7 +144,13 @@ def accept( ) def reject(self, status_code: types.SIPStatus = types.SIPStatus.BUSY_HERE) -> None: - """Reject the inbound call. + """ + Reject the inbound call with the given status code. + + Common status codes include: + - [BUSY_HERE][voip.sip.types.SIPStatus.BUSY_HERE]: The remote party will hear a busy signal. + - [DECLINE][voip.sip.types.SIPStatus.DECLINE]: The remote party will hera a decline signal. + - [DOES_NOT_EXIST_ANYWHERE][voip.sip.types.SIPStatus.DOES_NOT_EXIST_ANYWHERE]: The remote party will hear a "The person you are trying to reach…" message. Args: status_code: SIP response status code (default: 486 Busy Here). @@ -151,10 +159,7 @@ def reject(self, status_code: types.SIPStatus = types.SIPStatus.BUSY_HERE) -> No self.invite_transaction.reject(status_code) async def bye(self) -> None: - """Terminate the dialog by sending a SIP BYE request [RFC 3261 §15]. - - [RFC 3261 §15]: https://datatracker.ietf.org/doc/html/rfc3261#section-15 - """ + """End the call and terminate the dialog and multimedia session.""" from voip.sip.transactions import ByeTransaction # noqa: PLC0415 try: @@ -172,17 +177,18 @@ async def bye(self) -> None: async def dial( self, - target: str, + target: SipUri, *, session_class: type[Session], **session_kwargs: typing.Any, ) -> None: - """Initiate an outbound call to *target* [RFC 3261 §13.1]. + """ + Initiate an outbound call to *target*. Args: - target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). + target: SIP URI of the remote party (e.g. ``"sip:+15551234567@carrier.com"``). session_class: Session subclass to create for this call. - **session_kwargs: Extra keyword arguments forwarded to `call_class`. + **session_kwargs: Extra keyword arguments forwarded to `session_class`. [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 """ diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 01bb3bc..491d7a5 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -49,37 +49,40 @@ class SessionInitiationProtocol(asyncio.Protocol): """ SIP User Agent Client (UAC) over TLS/TCP [RFC 3261]. - Handles incoming calls and, optionally, carrier registration with digest - authentication [RFC 3261 §22]. All signaling is sent over a single - persistent TLS/TCP connection. - - Subclass [Dialog][voip.sip.dialog.Dialog] and override - [call_received][voip.sip.dialog.Dialog.call_received] to handle - inbound calls, then register it as `dialog_class`: - - ```python - class MyDialog(Dialog): - def call_received(self) -> None: - self.ringing() - self.accept(call_class=MyCall) - - class MySession(SessionInitiationProtocol): - dialog_class = MyDialog - ``` - - For outbound calls, use - [Dialog.dial][voip.sip.dialog.Dialog.dial] from within - [on_registered][voip.sip.protocol.SessionInitiationProtocol.on_registered]: - - ```python - class MySession(SessionInitiationProtocol): - def on_registered(self) -> None: - dialog = MyDialog(sip=self) - asyncio.create_task(dialog.dial("sip:bob@biloxi.com", call_class=MyCall)) - ``` + Handles SIP message parsing, carrier registration, and transaction management. + + Example: + You can use the handler like any [asyncio.Protocol][asyncio.Protocol] in Python. + + ```python + import asyncio + + from voip.sip import SessionInitiationProtocol + + async def main(): + loop = asyncio.get_running_loop() + + transport, protocol = await loop.create_connection( + SessionInitiationProtocol, + '0.0.0.0', 5060) + + try: + await asyncio.Future() + finally: + transport.close() + + + asyncio.run(main()) + ``` + + However, this example is incomplete, since the protocol will require some + arguments, like a reference to the RTP protocol and an AOR. + + > [!Note] + > The support is limited to UAC (client mode). + > This library currently does not implement server (UAS) functionality. [RFC 3261]: https://datatracker.ietf.org/doc/html/rfc3261 - [RFC 3261 §22]: https://datatracker.ietf.org/doc/html/rfc3261#section-22 Args: aor: SIP Address of Record (AOR) to register with the carrier. @@ -138,7 +141,6 @@ async def send_keepalive(self) -> None: self.transport.write(PING) async def handle_registration(self, tx: RegistrationTransaction) -> None: - """Await carrier registration and invoke [on_registered][voip.sip.protocol.SessionInitiationProtocol.on_registered].""" await tx self.on_registered() diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index b778e51..988b003 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -105,6 +105,22 @@ def complete(self) -> None: if not self.done(): self.set_result(self.dialog) + @classmethod + async def receive( + cls, + *, + request: Request, + sip: SessionInitiationProtocol, + ): ... + + @classmethod + async def send( + cls, + *, + sip: SessionInitiationProtocol, + **kwargs: typing.Any, + ): ... + @classmethod def from_request( cls, @@ -610,7 +626,7 @@ async def send( cls, *, sip: SessionInitiationProtocol, - target: str, + target: types.SipUri, dialog: Dialog, session_class: type[Session], **session_kwargs: typing.Any, @@ -634,7 +650,6 @@ async def send( dialog.uac = sip.aor dialog.sip = sip - target_uri = types.SipUri.parse(target) tx = cls( sip=sip, method=SIPMethod.INVITE, @@ -677,12 +692,12 @@ async def send( ) tx.request = Request( method=SIPMethod.INVITE, - uri=target_uri, + uri=target, headers={ "Max-Forwards": "70", **tx.headers, "From": dialog.from_header, - "To": str(target_uri), + "To": str(target), "Contact": sip.contact, "Call-ID": dialog.call_id, "Route": f"", diff --git a/voip/sip/types.py b/voip/sip/types.py index 20fed8a..f5e3305 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -241,250 +241,166 @@ def __repr__(self) -> str: class SIPStatus(enum.IntEnum): - """ - SIP Status Codes based on [RFC 3261]. + """SIP Status Codes based on [RFC 3261]. [RFC 3261]: https://datatracker.ietf.org/doc/html/rfc3261#section-21 """ - def __new__(cls, value, phrase, description=""): + def __new__(cls, value: int, phrase: str) -> SIPStatus: obj = int.__new__(cls, value) obj._value_ = value obj.phrase = phrase - obj.description = description return obj - TRYING = ( - 100, - "Trying", - "The request is being processed. No final response is available yet.", - ) - RINGING = 180, "Ringing", "The called party is being alerted of the call." - CALL_IS_BEING_FORWARDED = ( - 181, - "Call Is Being Forwarded", - "The called party is being alerted of the call, but the call is not yet established.", - ) - QUEUED = ( - 182, - "Queued", - "The called party is being alerted of the call, but the call is not yet established.", - ) - SESSION_PROGRESS = ( - 183, - "Session Progress", - "The called party is being alerted of the call, but the call is not yet established.", - ) + TRYING = 100, "Trying" + """The request is being processed. No final response is available yet.""" - OK = 200, "OK", "The request has succeeded." + RINGING = 180, "Ringing" + """The called party is being alerted of the call.""" - MULTIPLE_CHOICES = ( - 300, - "Multiple Choices", - "The requested resource has multiple representations, each with its own specific location.", - ) - MOVED_PERMANENTLY = ( - 301, - "Moved Permanently", - "The requested resource has been assigned a new permanent URI and any future references to this resource ought to use one of the returned URIs.", - ) - MOVED_TEMPORARILY = ( - 302, - "Moved Temporarily", - "The requested resource is temporarily unavailable and the server is asking the client to try again later.", - ) - USE_PROXY = ( - 305, - "Use Proxy", - "The requested resource is available only through a proxy, the address for which is provided in the response.", - ) - ALTERNATIVE_SERVICE = ( - 380, - "Alternative Service", - "The server has fulfilled a request for the service indicated by the URI.", - ) + CALL_IS_BEING_FORWARDED = 181, "Call Is Being Forwarded" + """The called party is being alerted of the call, but the call is not yet established.""" - BAD_REQUEST = ( - 400, - "Bad Request", - "The request has bad syntax or cannot be fulfilled due to bad syntax.", - ) - UNAUTHORIZED = 401, "Unauthorized", "The request requires user authentication." - PAYMENT_REQUIRED = 402, "Payment Required", "Further action is required." - FORBIDDEN = ( - 403, - "Forbidden", - "The server understood the request but refuses to fulfill it.", - ) - NOT_FOUND = 404, "Not Found", "The requested resource could not be found." - METHOD_NOT_ALLOWED = ( - 405, - "Method Not Allowed", - "The method specified in the Request-URI is not allowed for the resource identified by the request URI.", - ) - NOT_ACCEPTABLE = ( - 406, - "Not Acceptable", - "The server cannot produce a response matching the Accept headers.", - ) - PROXY_AUTHENTICATION_REQUIRED = ( - 407, - "Proxy Authentication Required", - "The client must authenticate itself with the proxy.", - ) - REQUEST_TIMEOUT = ( - 408, - "Request Timeout", - "The server timed out waiting for the request.", - ) - GONE = ( - 410, - "Gone", - "The requested resource is no longer available at the server and no longer exists.", - ) - REQUEST_ENTITY_TOO_LARGE = ( - 413, - "Request Entity Too Large", - "The server will not accept the request, because the entity of the request is too large.", - ) - REQUEST_URI_TOO_LONG = ( - 414, - "Request-URI Too Long", - "The server will not accept the request, because the Request-URI is too long.", - ) - UNSUPPORTED_MEDIA_TYPE = ( - 415, - "Unsupported Media Type", - "The server will not accept the request, because the media type of the request is unsupported.", - ) - UNSUPPORTED_URI_SCHEME = ( - 416, - "Unsupported URI Scheme", - "The server will not accept the request, because the URI scheme of the request is unsupported.", - ) - BAD_EXTENSION = ( - 420, - "Bad Extension", - "This status code indicates that the server does not recognize the value of any of the parameters that it needs to understand in the request.", - ) - EXTENSION_REQUIRED = ( - 421, - "Extension Required", - "This status code indicates that the server requires the client to identify itself (usually, using the Contact header field) before it will proceed with the request.", - ) - INTERVAL_TOO_BRIEF = ( - 423, - "Interval Too Brief", - "This status code indicates that the server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large.", - ) - TEMPORARILY_UNAVAILABLE = ( - 480, - "Temporarily Unavailable", - "This status code indicates that the server is currently unable to handle the request due to a temporary overloading or maintenance of the server.", - ) - CALL_TRANSACTION_DOES_NOT_EXIST = ( - 481, - "Call/Transaction Does Not Exist", - "This status code indicates that the server has received a final response for the transaction which it is still attempting to complete.", - ) - LOOP_DETECTED = ( - 482, - "Loop Detected", - "This status code indicates that the server has detected an infinite loop while processing the request.", - ) - TOO_MANY_HOPS = ( - 483, - "Too Many Hops", - "This status code indicates that the server has exceeded the maximum number of hops allowed in the request URI.", - ) - ADDRESS_INCOMPLETE = ( - 484, - "Address Incomplete", - "This status code indicates that the server has received a final response for the transaction which it is still attempting to complete, but has an invalid value for one or more of the header fields included in the request message.", - ) - AMBIGUOUS = ( - 485, - "Ambiguous", - "This status code indicates that the server cannot decide on a response to the request because multiple responses are possible.", - ) - BUSY_HERE = ( - 486, - "Busy Here", - "This status code indicates that the server is busy here.", - ) - REQUEST_TERMINATED = ( - 487, - "Request Terminated", - "This status code indicates that the server has received a final response for the transaction which it is still attempting to complete, but has received a termination request for that transaction from the client.", - ) - NOT_ACCEPTABLE_HERE = ( - 488, - "Not Acceptable Here", - "This status code indicates that the server is not able to produce a response which is acceptable to the client, according to the proactive negotiation header fields received in the request, and the server is unwilling to supply a default reason phrase.", - ) - REQUEST_PENDING = ( - 491, - "Request Pending", - "This status code indicates that the server has received a final response for the transaction which it is still attempting to complete, but has not yet delivered that response to the client.", - ) - UNDECIPHERABLE = ( - 493, - "Undecipherable", - "This status code indicates that the server was unable to decrypt a message after performing the necessary decryption(s).", - ) + QUEUED = 182, "Queued" + """The called party is being alerted of the call, but the call is not yet established.""" - SERVER_INTERNAL_ERROR = ( - 500, - "Server Internal Error", - "The server encountered an unexpected condition which prevented it from fulfilling the request.", - ) - NOT_IMPLEMENTED = ( - 501, - "Not Implemented", - "The server does not support the functionality required to fulfill the request.", - ) - BAD_GATEWAY = ( - 502, - "Bad Gateway", - "The server, while acting as a gateway or proxy, received an invalid response from the upstream server it accessed in attempting to fulfill the request.", - ) - SERVICE_UNAVAILABLE = ( - 503, - "Service Unavailable", - "The server is currently unable to handle the request due to a temporary overloading or maintenance of the server.", - ) - SERVER_TIME_OUT = ( - 504, - "Server Time-out", - "The server, while acting as a gateway or proxy, did not receive a timely response from the upstream server specified by the URI (e.g., HTTP, FTP, LDAP) or some other auxiliary server (e.g., DNS) it needed to access in attempting to complete the request.", - ) - VERSION_NOT_SUPPORTED = ( - 505, - "Version Not Supported", - "The server does not support, or refuses to support, the protocol version that was used in the request message.", - ) - MESSAGE_TOO_LARGE = ( - 513, - "Message Too Large", - "The server is unwilling to process the request because its header fields are too large.", - ) + SESSION_PROGRESS = 183, "Session Progress" + """The called party is being alerted of the call, but the call is not yet established.""" - BUSY_EVERYWHERE = ( - 600, - "Busy Everywhere", - "The server is not able to process the request because it is busy. For example, this error might be given if a server is overloaded with requests and is unable to process one of the requests.", - ) - DECLINE = 603, "Decline", "The call has been declined." - DOES_NOT_EXIST_ANYWHERE = ( - 604, - "Does Not Exist Anywhere", - "The server has received a final response for the transaction which it is still attempting to complete, but has received a termination request for that transaction from a server which it does not control.", - ) - NOT_ACCEPTABLE_ANYWHERE = ( - 606, - "Not Acceptable", - "The server is not able to produce a response which is acceptable to the client, according to the proactive negotiation header fields received in the request, and the server is unwilling to supply a default reason phrase.", - ) + OK = 200, "OK" + """The request has succeeded.""" + + MULTIPLE_CHOICES = 300, "Multiple Choices" + """The requested resource has multiple representations, each with its own specific location.""" + + MOVED_PERMANENTLY = 301, "Moved Permanently" + """The requested resource has been assigned a new permanent URI and any future references to this resource ought to use one of the returned URIs.""" + + MOVED_TEMPORARILY = 302, "Moved Temporarily" + """The requested resource is temporarily unavailable and the server is asking the client to try again later.""" + + USE_PROXY = 305, "Use Proxy" + """The requested resource is available only through a proxy, the address for which is provided in the response.""" + + ALTERNATIVE_SERVICE = 380, "Alternative Service" + """The server has fulfilled a request for the service indicated by the URI.""" + + BAD_REQUEST = 400, "Bad Request" + """The request has bad syntax or cannot be fulfilled due to bad syntax.""" + + UNAUTHORIZED = 401, "Unauthorized" + """The request requires user authentication.""" + + PAYMENT_REQUIRED = 402, "Payment Required" + """Further action is required.""" + + FORBIDDEN = 403, "Forbidden" + """The server understood the request but refuses to fulfill it.""" + + NOT_FOUND = 404, "Not Found" + """The requested resource could not be found.""" + + METHOD_NOT_ALLOWED = 405, "Method Not Allowed" + """The method specified in the Request-URI is not allowed for the resource identified by the request URI.""" + + NOT_ACCEPTABLE = 406, "Not Acceptable" + """The server cannot produce a response matching the Accept headers.""" + + PROXY_AUTHENTICATION_REQUIRED = 407, "Proxy Authentication Required" + """The client must authenticate itself with the proxy.""" + + REQUEST_TIMEOUT = 408, "Request Timeout" + """The server timed out waiting for the request.""" + + GONE = 410, "Gone" + """The requested resource is no longer available at the server and no longer exists.""" + + REQUEST_ENTITY_TOO_LARGE = 413, "Request Entity Too Large" + """The server will not accept the request, because the entity of the request is too large.""" + + REQUEST_URI_TOO_LONG = 414, "Request-URI Too Long" + """The server will not accept the request, because the Request-URI is too long.""" + + UNSUPPORTED_MEDIA_TYPE = 415, "Unsupported Media Type" + """The server will not accept the request, because the media type of the request is unsupported.""" + + UNSUPPORTED_URI_SCHEME = 416, "Unsupported URI Scheme" + """The server will not accept the request, because the URI scheme of the request is unsupported.""" + + BAD_EXTENSION = 420, "Bad Extension" + """This status code indicates that the server does not recognize the value of any of the parameters that it needs to understand in the request.""" + + EXTENSION_REQUIRED = 421, "Extension Required" + """This status code indicates that the server requires the client to identify itself (usually, using the Contact header field) before it will proceed with the request.""" + + INTERVAL_TOO_BRIEF = 423, "Interval Too Brief" + """This status code indicates that the server is unwilling to process the request because either an individual header field, or all the header fields collectively, are too large.""" + + TEMPORARILY_UNAVAILABLE = 480, "Temporarily Unavailable" + """This status code indicates that the server is currently unable to handle the request due to a temporary overloading or maintenance of the server.""" + + CALL_TRANSACTION_DOES_NOT_EXIST = 481, "Call/Transaction Does Not Exist" + """This status code indicates that the server has received a final response for the transaction which it is still attempting to complete.""" + + LOOP_DETECTED = 482, "Loop Detected" + """This status code indicates that the server has detected an infinite loop while processing the request.""" + + TOO_MANY_HOPS = 483, "Too Many Hops" + """This status code indicates that the server has exceeded the maximum number of hops allowed in the request URI.""" + + ADDRESS_INCOMPLETE = 484, "Address Incomplete" + """This status code indicates that the server has received a final response for the transaction which it is still attempting to complete, but has an invalid value for one or more of the header fields included in the request message.""" + + AMBIGUOUS = 485, "Ambiguous" + """This status code indicates that the server cannot decide on a response to the request because multiple responses are possible.""" + + BUSY_HERE = 486, "Busy Here" + """This status code indicates that the server is busy here.""" + + REQUEST_TERMINATED = 487, "Request Terminated" + """This status code indicates that the server has received a final response for the transaction which it is still attempting to complete, but has received a termination request for that transaction from the client.""" + + NOT_ACCEPTABLE_HERE = 488, "Not Acceptable Here" + """This status code indicates that the server is not able to produce a response which is acceptable to the client, according to the proactive negotiation header fields received in the request, and the server is unwilling to supply a default reason phrase.""" + + REQUEST_PENDING = 491, "Request Pending" + """This status code indicates that the server has received a final response for the transaction which it is still attempting to complete, but has not yet delivered that response to the client.""" + + UNDECIPHERABLE = 493, "Undecipherable" + """This status code indicates that the server was unable to decrypt a message after performing the necessary decryption(s).""" + + SERVER_INTERNAL_ERROR = 500, "Server Internal Error" + """The server encountered an unexpected condition which prevented it from fulfilling the request.""" + + NOT_IMPLEMENTED = 501, "Not Implemented" + """The server does not support the functionality required to fulfill the request.""" + + BAD_GATEWAY = 502, "Bad Gateway" + """The server, while acting as a gateway or proxy, received an invalid response from the upstream server it accessed in attempting to fulfill the request.""" + + SERVICE_UNAVAILABLE = 503, "Service Unavailable" + """The server is currently unable to handle the request due to a temporary overloading or maintenance of the server.""" + + SERVER_TIME_OUT = 504, "Server Time-out" + """The server, while acting as a gateway or proxy, did not receive a timely response from the upstream server specified by the URI (e.g., HTTP, FTP, LDAP) or some other auxiliary server (e.g., DNS) it needed to access in attempting to complete the request.""" + + VERSION_NOT_SUPPORTED = 505, "Version Not Supported" + """The server does not support, or refuses to support, the protocol version that was used in the request message.""" + + MESSAGE_TOO_LARGE = 513, "Message Too Large" + """The server is unwilling to process the request because its header fields are too large.""" + + BUSY_EVERYWHERE = 600, "Busy Everywhere" + """The server is not able to process the request because it is busy. For example, this error might be given if a server is overloaded with requests and is unable to process one of the requests.""" + + DECLINE = 603, "Decline" + """The call has been declined.""" + + DOES_NOT_EXIST_ANYWHERE = 604, "Does Not Exist Anywhere" + """The server has received a final response for the transaction which it is still attempting to complete, but has received a termination request for that transaction from a server which it does not control.""" + + NOT_ACCEPTABLE_ANYWHERE = 606, "Not Acceptable" + """The server is not able to produce a response which is acceptable to the client, according to the proactive negotiation header fields received in the request, and the server is unwilling to supply a default reason phrase.""" class SIPMethod(enum.StrEnum): From 82791559d3949adb1d3f64675ed0d86b7d56a89d Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 2 Apr 2026 19:51:21 +0200 Subject: [PATCH 23/45] more cleanup --- CONTRIBUTING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9af57c5..5fec614 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ curl -sSL https://raw.githubusercontent.com/codingjoe/naming-things/refs/heads/m To run the tests, use the following command: -```bash +```console uv run pytest ``` @@ -18,13 +18,13 @@ You may only mock transports to avoid network IO or to mimic network counterpart ## Testing with Extra Dependencies -```bash +```console uv run --extra=cli --extra=pygments --extra=audio pytest ``` Before your first commit, ensure that the pre-commit hooks are installed by running: -```bash +```console uvx prek install ``` @@ -34,6 +34,6 @@ The documentation is built using [MkDocs](https://www.mkdocs.org/) with [mkdocst To serve the documentation locally for development, run: -```bash +```console uv run --group docs mkdocs serve --livereload ``` From a01aa29080679ea3a6444787d3d7c3ba5094ddec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:26:03 +0000 Subject: [PATCH 24/45] Fix tests: add dialog=Dialog() to Session instantiations in test_rtp.py Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/b4fc49c4-e14b-4b77-a270-87dc259a6ecd Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- tests/test_rtp.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/test_rtp.py b/tests/test_rtp.py index a6e432d..4d4f732 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -9,6 +9,7 @@ import pytest from voip.rtp import RTP, RealtimeTransportProtocol, RTPPacket, RTPPayloadType, Session from voip.sdp.types import MediaDescription, RTPPayloadFormat +from voip.sip.dialog import Dialog from voip.sip.types import CallerID @@ -23,6 +24,7 @@ def make_call(**kwargs) -> Session: """Create an RTPCall with mock rtp for unit testing.""" defaults: dict = { "rtp": MagicMock(spec=RealtimeTransportProtocol), + "dialog": Dialog(), "media": make_media(), "caller": CallerID(""), } @@ -160,7 +162,7 @@ def packet_received(self, packet: RTPPacket, addr): routed.append(packet) mux = RealtimeTransportProtocol() - handler = RecordCall(rtp=mux, media=make_media(), caller=CallerID("")) + handler = RecordCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) remote_addr = ("127.0.0.1", 5004) mux.register_call(remote_addr, handler) rtp_packet = make_rtp_packet(payload=b"audio") @@ -185,7 +187,7 @@ def packet_received(self, packet: RTPPacket, addr): routed.append(packet) mux = RealtimeTransportProtocol() - handler = RecordCall(rtp=mux, media=make_media(), caller=CallerID("")) + handler = RecordCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) mux.register_call(("127.0.0.1", 5004), handler) # 5 bytes is shorter than the 12-byte minimum RTP header — must not raise. mux.datagram_received(b"\x80\x00\x00\x01\x00", ("127.0.0.1", 5004)) @@ -201,7 +203,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() mux.connection_made(MagicMock(spec=asyncio.DatagramTransport)) - handler = RecordCall(rtp=mux, media=make_media(), caller=CallerID("")) + handler = RecordCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) mux.register_call(None, handler) stun_bytes = b"\x01\x01" + b"\x00" * 18 # first byte = 1 (STUN range [0,3]) mux.datagram_received(stun_bytes, ("127.0.0.1", 5004)) @@ -286,10 +288,10 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() specific_addr = ("1.2.3.4", 5004) wildcard_handler = WildcardCall( - rtp=mux, media=make_media(), caller=CallerID("") + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") ) specific_handler = SpecificCall( - rtp=mux, media=make_media(), caller=CallerID("") + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") ) mux.register_call(None, wildcard_handler) mux.register_call(specific_addr, specific_handler) @@ -310,7 +312,7 @@ def packet_received(self, packet: RTPPacket, addr): received.append(packet) mux = RealtimeTransportProtocol() - handler = WildcardCall(rtp=mux, media=make_media(), caller=CallerID("")) + handler = WildcardCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) mux.register_call(None, handler) rtp_packet = make_rtp_packet(payload=b"unmatched") @@ -328,7 +330,7 @@ def packet_received(self, packet: RTPPacket, addr): received.append(packet) mux = RealtimeTransportProtocol() - handler = RecordCall(rtp=mux, media=make_media(), caller=CallerID("")) + handler = RecordCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) remote_addr = ("5.6.7.8", 5004) mux.register_call(remote_addr, handler) mux.unregister_call(remote_addr) @@ -342,7 +344,7 @@ async def test_register_call__logs_info(self, caplog): import logging # noqa: PLC0415 mux = RealtimeTransportProtocol() - handler = Session(rtp=mux, media=make_media(), caller=CallerID("")) + handler = Session(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) with caplog.at_level(logging.INFO, logger="voip.rtp"): mux.register_call(("1.2.3.4", 5004), handler) assert any("rtp_call_registered" in r.message for r in caplog.records) @@ -353,7 +355,7 @@ async def test_unregister_call__logs_info(self, caplog): import logging # noqa: PLC0415 mux = RealtimeTransportProtocol() - handler = Session(rtp=mux, media=make_media(), caller=CallerID("")) + handler = Session(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) addr = ("1.2.3.4", 5004) mux.register_call(addr, handler) with caplog.at_level(logging.INFO, logger="voip.rtp"): @@ -370,7 +372,7 @@ def packet_received(self, packet: RTPPacket, addr): received.append((packet, addr)) mux = RealtimeTransportProtocol() - handler = CapturingCall(rtp=mux, media=make_media(), caller=CallerID("")) + handler = CapturingCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) mux.register_call(None, handler) packet = make_rtp_packet() mux.packet_received(packet, ("1.2.3.4", 5004)) @@ -413,6 +415,7 @@ def packet_received(self, packet: RTPPacket, addr) -> None: session = SRTPSession.generate() handler = SRTPCapture( rtp=mux, + dialog=Dialog(), media=make_media(), srtp=session, caller=CallerID(""), @@ -444,6 +447,7 @@ def packet_received(self, packet: RTPPacket, addr) -> None: session = SRTPSession.generate() handler = SRTPCapture( rtp=mux, + dialog=Dialog(), media=make_media(), srtp=session, caller=CallerID(""), @@ -479,7 +483,7 @@ def test_media__stored_on_instance(self): def test_rtp_stored_as_field(self): """Rtp back-reference is stored on the instance.""" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - call = Session(rtp=mock_rtp, media=make_media(), caller=CallerID("")) + call = Session(rtp=mock_rtp, dialog=Dialog(), media=make_media(), caller=CallerID("")) assert call.rtp is mock_rtp def test_packet_received__noop_by_default(self): From aeb89ce4c05623635bc9efa19ac5fec4ce3a35f5 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 2 Apr 2026 21:43:09 +0200 Subject: [PATCH 25/45] Refactor URIs --- docs/sip.md | 2 + tests/sip/test_types.py | 94 +++++++++++++++- voip/sip/__init__.py | 3 +- voip/sip/types.py | 241 ++++++++++++++++++++++++++++++---------- 4 files changed, 277 insertions(+), 63 deletions(-) diff --git a/docs/sip.md b/docs/sip.md index 4c15506..3e6b91a 100644 --- a/docs/sip.md +++ b/docs/sip.md @@ -20,6 +20,8 @@ ::: voip.sip.SipUri +::: voip.sip.TelUri + ::: voip.sip.CallerID ::: voip.sip.SIPStatus diff --git a/tests/sip/test_types.py b/tests/sip/test_types.py index 6d9138c..7a5b567 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -1,7 +1,7 @@ import ipaddress import pytest -from voip.sip import SipUri +from voip.sip import SipUri, TelUri from voip.sip.messages import Response from voip.sip.types import CallerID @@ -286,6 +286,10 @@ def test_ttl__returns_value(self): uri = SipUri.parse("sip:alice@example.com;ttl=30") assert uri.ttl == 30 + def test_ttl__absent(self): + """Return None when the ttl parameter is absent.""" + assert SipUri.parse("sip:alice@example.com").ttl is None + def test_transport__sips_returns_tls(self): """Return 'TLS' for sips: URIs that have no transport parameter.""" uri = SipUri.parse("sips:alice@example.com") @@ -301,6 +305,64 @@ def test_transport__explicit_parameter(self): uri = SipUri.parse("sip:alice@example.com;transport=udp") assert uri.transport == "UDP" + def test_isinstance__str(self): + """SipUri instances are also plain str instances.""" + assert isinstance(SipUri.parse("sip:alice@example.com"), str) + + +class TestTelUri: + @pytest.mark.parametrize( + "uri_str, number, is_global", + [ + ("tel:+15551234567", "+15551234567", True), + ("tel:1234", "1234", False), + ("tel:+1-202-555-0100", "+1-202-555-0100", True), + ], + ) + def test_parse__valid(self, uri_str, number, is_global): + """Parse number and global flag from a valid tel: URI.""" + uri = TelUri.parse(uri_str) + assert uri.number == number + assert uri.is_global is is_global + + def test_parse__with_phone_context(self): + """Parse phone-context parameter from a local tel: URI.""" + uri = TelUri.parse("tel:1234;phone-context=example.com") + assert uri.number == "1234" + assert uri.phone_context == "example.com" + + def test_parse__phone_context_absent(self): + """Return None for phone_context when the parameter is absent.""" + assert TelUri.parse("tel:+15551234567").phone_context is None + + @pytest.mark.parametrize( + "uri_str", + [ + "sip:alice@example.com", + "http://example.com", + "tel:", + ], + ) + def test_parse__invalid(self, uri_str): + """Raise ValueError when parsing an invalid tel: URI.""" + with pytest.raises(ValueError): + TelUri.parse(uri_str) + + def test_str__global_number(self): + """Canonical string equals the original tel: URI for a global number.""" + assert str(TelUri.parse("tel:+15551234567")) == "tel:+15551234567" + + def test_str__with_parameters(self): + """Canonical string includes parameters.""" + assert ( + str(TelUri.parse("tel:1234;phone-context=example.com")) + == "tel:1234;phone-context=example.com" + ) + + def test_isinstance__str(self): + """TelUri instances are also plain str instances.""" + assert isinstance(TelUri.parse("tel:+15551234567"), str) + def _ok() -> Response: return Response(status_code=200, phrase="OK") @@ -370,6 +432,36 @@ def test_repr__no_host(self): masked = repr(CallerID("notasipuri")) assert "@" not in masked + def test_uri__sip(self): + """Extract a SipUri from a SIP CallerID.""" + assert isinstance(CallerID("sip:alice@example.com").uri, SipUri) + + def test_uri__sip_angle_brackets(self): + """Extract SipUri from a CallerID with angle-bracket notation.""" + assert isinstance( + CallerID('"Alice" ;tag=abc').uri, SipUri + ) + + def test_uri__tel(self): + """Extract a TelUri from a tel: CallerID.""" + assert isinstance(CallerID("tel:+15551234567").uri, TelUri) + + def test_uri__absent(self): + """Return None when no URI is present.""" + assert CallerID("plain string").uri is None + + def test_uri__unparseable(self): + """Return None when the URI-like string is not valid for any parser.""" + assert CallerID("sip:@invalid").uri is None + + def test_user__tel_number(self): + """Return the tel number as user for a tel: CallerID.""" + assert CallerID("tel:+15551234567").user == "+15551234567" + + def test_host__tel_absent(self): + """Return None for host when the CallerID is a tel URI.""" + assert CallerID("tel:+15551234567").host is None + class TestMaskCaller: def test_mask_caller__with_display_name(self): diff --git a/voip/sip/__init__.py b/voip/sip/__init__.py index 9899f0c..42e3821 100644 --- a/voip/sip/__init__.py +++ b/voip/sip/__init__.py @@ -7,11 +7,12 @@ from .dialog import Dialog from .messages import Message, Request, Response from .protocol import SessionInitiationProtocol -from .types import CallerID, SIPMethod, SIPStatus, SipUri +from .types import CallerID, SIPMethod, SIPStatus, SipUri, TelUri __all__ = [ "CallerID", "SipUri", + "TelUri", "SIPStatus", "SIPMethod", "Message", diff --git a/voip/sip/types.py b/voip/sip/types.py index f5e3305..cc1983e 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -1,4 +1,3 @@ -import dataclasses import enum import ipaddress import re @@ -8,6 +7,7 @@ "DigestAlgorithm", "DigestQoP", "SipUri", + "TelUri", "SIPStatus", "SIPMethod", ] @@ -22,58 +22,49 @@ pass -@dataclasses.dataclass(slots=True, eq=True) -class SipUri: +class SipUri(str): """A parsed SIP or SIPS URI per [RFC 3261 §19.1]. Format: ``sip:user:password@host:port;uri-parameters?headers`` - The `parse` classmethod decodes a raw SIP URI string into structured - fields. IPv6 addresses in the host part must be enclosed in square - brackets per [RFC 2732] (e.g. ``sip:alice@[::1]:5060``); the stored - `host` is the bare address without brackets. + Behaves as a plain ``str`` holding the canonical URI, so instances can be + stored in header dicts unchanged. The `parse` classmethod decodes a raw + SIP URI string into structured fields. IPv6 addresses in the host part + must be enclosed in square brackets per [RFC 2732] + (e.g. ``sip:alice@[::1]:5060``); the stored `host` is the bare address + without brackets. [RFC 3261 §19.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-19.1 [RFC 2732]: https://datatracker.ietf.org/doc/html/rfc2732 Examples: >>> SipUri.parse("sip:alice@example.com") - SipUri(scheme='sip', user='alice', host='example.com', ...) + 'sip:alice@example.com:5060' >>> SipUri.parse("sips:+15551234567@carrier.com:5061") - SipUri(scheme='sips', user='+15551234567', host='carrier.com', port=5061, ...) + 'sips:%2B15551234567@carrier.com:5061' >>> SipUri.parse("sip:alice@[::1]:5060") - SipUri(scheme='sip', user='alice', host=IPv6Address('::1'), port=5060, ...) + 'sip:alice@[::1]:5060' Args: scheme: URI scheme — `sip` or `sips`. host: Host as a bare string — no brackets for IPv6 addresses. user: SIP user part (phone number or username). + password: Optional password in the user-info component. port: Port number. 5061 for `sips:` and 5060 for `sip:`. parameters: URI parameters as a mapping of name → value (`None` for flag parameters). headers: SIP headers as a mapping of name → value. """ + __slots__ = ("scheme", "host", "user", "password", "port", "parameters", "headers") + scheme: str host: str | ipaddress.IPv6Address | ipaddress.IPv4Address - user: str | None = None - password: str | None = dataclasses.field(default=None, repr=False) - port: int | None = None - parameters: dict[str, str | None] = dataclasses.field(default_factory=dict) - headers: dict[str, str] = dataclasses.field(default_factory=dict) - - def __post_init__(self): - self.port = ( - self.port - if self.port is not None - else 5061 - if self.scheme == "sips" - else 5060 - ) - try: - self.host = ipaddress.ip_address(self.host) - except ValueError: - pass + user: str | None + password: str | None + port: int + parameters: dict[str, str | None] + headers: dict[str, str] SIP_URL_PATTERN: typing.ClassVar[re.Pattern[str]] = re.compile( r"^(?Psips?):" @@ -85,6 +76,57 @@ def __post_init__(self): re.IGNORECASE, ) + def __new__( + cls, + scheme: str, + host: str | ipaddress.IPv6Address | ipaddress.IPv4Address, + user: str | None = None, + password: str | None = None, + port: int | None = None, + parameters: dict[str, str | None] | None = None, + headers: dict[str, str] | None = None, + ) -> SipUri: + try: + host = ipaddress.ip_address(host) + except ValueError: + pass + port = port if port is not None else (5061 if scheme == "sips" else 5060) + parameters = parameters or {} + headers = headers or {} + parts = [f"{scheme}:"] + if user: + parts.append(urllib.parse.quote(user)) + if password: + parts.append(f":{urllib.parse.quote(password)}") + parts.append("@") + parts.append( + f"[{host}]" if isinstance(host, ipaddress.IPv6Address) else str(host) + ) + parts.append(f":{port}") + for name, val in parameters.items(): + parts.append( + f";{urllib.parse.quote(name)}={urllib.parse.quote(val)}" + if val is not None + else f";{urllib.parse.quote(name)}" + ) + if headers: + parts.append("?") + parts.append( + "&".join( + f"{urllib.parse.quote(name)}={urllib.parse.quote(val)}" + for name, val in headers.items() + ) + ) + instance = super().__new__(cls, "".join(parts)) + instance.scheme = scheme + instance.host = host + instance.user = user + instance.password = password + instance.port = port + instance.parameters = parameters + instance.headers = headers + return instance + @classmethod def parse(cls, value: str) -> SipUri: """ @@ -140,34 +182,6 @@ def _parse_headers(cls, headers: str) -> Iterator[tuple[str, str]]: elif part: yield urllib.parse.unquote(part), "" - def __str__(self) -> str: - parts = [f"{self.scheme}:"] - if self.user: - parts.append(urllib.parse.quote(self.user)) - if self.password: - parts.append(f":{urllib.parse.quote(self.password)}") - parts.append("@") - parts.append( - f"[{str(self.host)}]" - if isinstance(self.host, ipaddress.IPv6Address) - else str(self.host) - ) - parts.append(f":{self.port}") - for name, val in self.parameters.items(): - if val is not None: - parts.append(f";{urllib.parse.quote(name)}={urllib.parse.quote(val)}") - else: - parts.append(f";{urllib.parse.quote(name)}") - if self.headers: - parts.append("?") - parts.append( - "&".join( - f"{urllib.parse.quote(name)}={urllib.parse.quote(val)}" - for name, val in self.headers.items() - ) - ) - return "".join(parts) - @property def maddr(self) -> NetworkAddress: try: @@ -191,6 +205,88 @@ def transport(self): ) +class TelUri(str): + """A tel: URI per [RFC 3966]. + + Format: ``tel:phone-number;parameters`` + + Behaves as a plain ``str`` holding the canonical URI. Global numbers + (E.164) start with ``+``. Local numbers carry a ``phone-context`` + parameter identifying the dialling context. + + [RFC 3966]: https://datatracker.ietf.org/doc/html/rfc3966 + + Examples: + >>> TelUri.parse("tel:+15551234567") + 'tel:+15551234567' + >>> TelUri.parse("tel:1234;phone-context=example.com") + 'tel:1234;phone-context=example.com' + + Args: + number: The phone number, including any visual separators. + parameters: URI parameters as a mapping of name → value (`None` for flag parameters). + + """ + + __slots__ = ("number", "parameters") + + number: str + parameters: dict[str, str | None] + + TEL_URL_PATTERN: typing.ClassVar[re.Pattern[str]] = re.compile( + r"^tel:(?P[+0-9A-F*#().,-]+)" + r"(?P;.*)?$", + re.IGNORECASE, + ) + + def __new__( + cls, + number: str, + parameters: dict[str, str | None] | None = None, + ) -> TelUri: + parameters = parameters or {} + parts = [f"tel:{number}"] + for name, val in parameters.items(): + parts.append( + f";{urllib.parse.quote(name)}={urllib.parse.quote(val)}" + if val is not None + else f";{urllib.parse.quote(name)}" + ) + instance = super().__new__(cls, "".join(parts)) + instance.number = number + instance.parameters = parameters + return instance + + @classmethod + def parse(cls, value: str) -> TelUri: + """Parse a tel: URI string into a `TelUri` instance. + + Returns: + Parsed `TelUri` instance. + + Raises: + ValueError: When the URI is malformed or uses an unsupported scheme. + """ + if match := cls.TEL_URL_PATTERN.fullmatch(value): + return cls( + number=match.group("number"), + parameters=dict(SipUri._parse_parameters(match.group("parameters"))) + if match.group("parameters") + else {}, + ) + raise ValueError(f"Invalid tel URI: {value!r}") + + @property + def is_global(self) -> bool: + """Whether this is a global (E.164) number, starting with `+`.""" + return self.number.startswith("+") + + @property + def phone_context(self) -> str | None: + """The `phone-context` parameter value, if present.""" + return self.parameters.get("phone-context") + + class CallerID(str): """SIP From/To header value with structured access and privacy-safe repr. @@ -215,17 +311,40 @@ def display_name(self) -> str | None: return (m.group(1) or m.group(2) or "").strip() or None return None + @property + def uri(self) -> SipUri | TelUri | None: + """Parsed SIP or tel URI embedded in the header value, if present.""" + if not (m := re.search(r"\s]+)>?", self)): + return None + raw = m.group(1) + try: + return SipUri.parse(raw) + except ValueError: + pass + try: + return TelUri.parse(raw) + except ValueError: + return None + @property def user(self) -> str | None: - """SIP user part (phone number or username).""" - m = re.search(r"sips?:([^@>;\s]+)@", self) - return m.group(1) if m else None + """SIP user part or telephone number.""" + match self.uri: + case SipUri() as sip: + return sip.user + case TelUri() as tel: + return tel.number + case _: + return None @property def host(self) -> str | None: """Carrier domain extracted from the SIP URI.""" - m = re.search(r"sips?:[^@>;\s]+@([^>;)\s,]+)", self) - return m.group(1) if m else None + match self.uri: + case SipUri() as sip: + return str(sip.host) + case _: + return None @property def tag(self) -> str | None: From 67f284fa719e718608ca6644ca0f7287acbecf42 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 2 Apr 2026 21:44:22 +0200 Subject: [PATCH 26/45] Rename --- README.md | 4 +- docs/sip.md | 4 +- tests/sip/conftest.py | 4 +- tests/sip/test_messages.py | 6 +-- tests/sip/test_types.py | 104 ++++++++++++++++++------------------- voip/__main__.py | 14 ++--- voip/sip/__init__.py | 6 +-- voip/sip/dialog.py | 10 ++-- voip/sip/messages.py | 6 +-- voip/sip/protocol.py | 2 +- voip/sip/transactions.py | 4 +- voip/sip/types.py | 40 +++++++------- 12 files changed, 102 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 3e0c0f0..b7ace65 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ import dataclasses import ssl from voip.ai import TranscribeCall from voip.sip.protocol import SIP -from voip.sip.types import SipUri +from voip.sip.types import SipURI from voip.sip.transactions import InviteTransaction from voip.rtp import RealtimeTransportProtocol from faster_whisper import WhisperModel @@ -101,7 +101,7 @@ async def main(): await loop.create_connection( lambda: SIP( rtp=rtp_protocol, - aor=SipUri.parse("sips:alice:********@example.com"), + aor=SipURI.parse("sips:alice:********@example.com"), transaction_class=TranscribeInviteTransaction, ), host="sip.example.com", diff --git a/docs/sip.md b/docs/sip.md index 3e6b91a..4a81937 100644 --- a/docs/sip.md +++ b/docs/sip.md @@ -18,9 +18,9 @@ ## Types -::: voip.sip.SipUri +::: voip.sip.SipURI -::: voip.sip.TelUri +::: voip.sip.TelURI ::: voip.sip.CallerID diff --git a/tests/sip/conftest.py b/tests/sip/conftest.py index 7a3f407..8423d69 100644 --- a/tests/sip/conftest.py +++ b/tests/sip/conftest.py @@ -8,7 +8,7 @@ from voip.sdp.types import MediaDescription, RTPPayloadFormat from voip.sip.dialog import Dialog from voip.sip.protocol import SessionInitiationProtocol -from voip.sip.types import SipUri +from voip.sip.types import SipURI from voip.types import NetworkAddress @@ -77,7 +77,7 @@ async def sip( ) -> SessionInitiationProtocol: """Return a connected SIP session with keepalive cancelled.""" session = SessionInitiationProtocol( - aor=SipUri.parse("sips:alice:secret@example.com:5061"), + aor=SipURI.parse("sips:alice:secret@example.com:5061"), rtp=rtp, dialog_class=Dialog, ) diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index f7198cf..9a6b777 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -4,7 +4,7 @@ from voip.sdp.messages import SessionDescription from voip.sip import messages from voip.sip.dialog import Dialog -from voip.sip.types import SipUri +from voip.sip.types import SipURI class TestHeaderMap: @@ -233,7 +233,7 @@ def test_branch__with_branch(self): def test_from_dialog__merges_dialog_headers(self): """Merge the provided headers with the dialog's headers.""" dialog = Dialog( - uac=SipUri.parse("sips:alice@example.com"), + uac=SipURI.parse("sips:alice@example.com"), local_tag="local-tag", remote_tag="remote-tag", ) @@ -292,7 +292,7 @@ def test_from_request__with_dialog_remote_tag(self): ) request = messages.Message.parse(data) dialog = Dialog( - uac=SipUri.parse("sip:alice@atlanta.com"), + uac=SipURI.parse("sip:alice@atlanta.com"), remote_tag="server-tag", ) response = messages.Response.from_request( diff --git a/tests/sip/test_types.py b/tests/sip/test_types.py index 7a5b567..be81450 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -1,7 +1,7 @@ import ipaddress import pytest -from voip.sip import SipUri, TelUri +from voip.sip import SipURI, TelURI from voip.sip.messages import Response from voip.sip.types import CallerID @@ -13,58 +13,58 @@ class TestSipUri: # domain ( "sip:alice@example.com", - SipUri(scheme="sip", user="alice", host="example.com", port=5060), + SipURI(scheme="sip", user="alice", host="example.com", port=5060), ), ( "sips:alice@example.com", - SipUri(scheme="sips", user="alice", host="example.com", port=5061), + SipURI(scheme="sips", user="alice", host="example.com", port=5061), ), ( "sip:alice@example.com:4050", - SipUri(scheme="sip", user="alice", host="example.com", port=4050), + SipURI(scheme="sip", user="alice", host="example.com", port=4050), ), ( "sips:alice@example.com:4051", - SipUri(scheme="sips", user="alice", host="example.com", port=4051), + SipURI(scheme="sips", user="alice", host="example.com", port=4051), ), # ipv4 ( "sip:alice@192.168.1.1", - SipUri(scheme="sip", user="alice", host="192.168.1.1", port=5060), + SipURI(scheme="sip", user="alice", host="192.168.1.1", port=5060), ), ( "sips:alice@192.168.1.1", - SipUri(scheme="sips", user="alice", host="192.168.1.1", port=5061), + SipURI(scheme="sips", user="alice", host="192.168.1.1", port=5061), ), ( "sip:alice@192.168.1.1:4050", - SipUri(scheme="sip", user="alice", host="192.168.1.1", port=4050), + SipURI(scheme="sip", user="alice", host="192.168.1.1", port=4050), ), ( "sips:alice@192.168.1.1:4051", - SipUri(scheme="sips", user="alice", host="192.168.1.1", port=4051), + SipURI(scheme="sips", user="alice", host="192.168.1.1", port=4051), ), # ipv6 ( "sip:alice@[::1]", - SipUri(scheme="sip", user="alice", host="::1", port=5060), + SipURI(scheme="sip", user="alice", host="::1", port=5060), ), ( "sips:alice@[::1]", - SipUri(scheme="sips", user="alice", host="::1", port=5061), + SipURI(scheme="sips", user="alice", host="::1", port=5061), ), ( "sip:alice@[::1]:4050", - SipUri(scheme="sip", user="alice", host="::1", port=4050), + SipURI(scheme="sip", user="alice", host="::1", port=4050), ), ( "sips:alice@[::1]:4051", - SipUri(scheme="sips", user="alice", host="::1", port=4051), + SipURI(scheme="sips", user="alice", host="::1", port=4051), ), # uri-parameters ( "sip:alice@example.com;transport=tcp", - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -74,7 +74,7 @@ class TestSipUri: ), ( "sip:alice@example.com;transport=udp;ttl=15", - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -85,7 +85,7 @@ class TestSipUri: # headers ( "sip:alice@example.com?foo=bar", - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -95,7 +95,7 @@ class TestSipUri: ), ( "sip:alice@example.com?tag=12345&foo=bar", - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -105,7 +105,7 @@ class TestSipUri: ), ( r"sip:%61lice@atlanta.com;transport=TCP", - SipUri( + SipURI( scheme="sip", user="alice", host="atlanta.com", @@ -115,7 +115,7 @@ class TestSipUri: ), ( r"sip:atlanta.com;method=REGISTER?to=alice%40atlanta.com", - SipUri( + SipURI( scheme="sip", user=None, host="atlanta.com", @@ -128,7 +128,7 @@ class TestSipUri: ) def test_parse_valid(self, uri_str, expected_uri_obj): """Parse scheme, user, host and optional port from a valid SIP URI.""" - assert SipUri.parse(uri_str) == expected_uri_obj + assert SipURI.parse(uri_str) == expected_uri_obj @pytest.mark.parametrize( "uri_str", @@ -141,17 +141,17 @@ def test_parse_valid(self, uri_str, expected_uri_obj): def test_parse_invalid(self, uri_str): """Raise ValueError when parsing an invalid SIP URI.""" with pytest.raises(ValueError): - SipUri.parse(uri_str) + SipURI.parse(uri_str) @pytest.mark.parametrize( "uri_obj, expected_uri_str", [ ( - SipUri(scheme="sip", user="alice", host="example.com", port=5061), + SipURI(scheme="sip", user="alice", host="example.com", port=5061), "sip:alice@example.com:5061", ), ( - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -161,7 +161,7 @@ def test_parse_invalid(self, uri_str): "sip:alice@example.com:5060;transport=TCP", ), ( - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -171,7 +171,7 @@ def test_parse_invalid(self, uri_str): "sip:alice@example.com:5060?foo=bar", ), ( - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -183,11 +183,11 @@ def test_parse_invalid(self, uri_str): ), # IPv6 ( - SipUri(scheme="sip", user="alice", host="::1", port=5060), + SipURI(scheme="sip", user="alice", host="::1", port=5060), "sip:alice@[::1]:5060", ), ( - SipUri( + SipURI( scheme="sip", user="alice", host=ipaddress.IPv6Address("::1"), @@ -197,11 +197,11 @@ def test_parse_invalid(self, uri_str): ), # IPv4 ( - SipUri(scheme="sip", user="alice", host="127.0.0.1", port=5060), + SipURI(scheme="sip", user="alice", host="127.0.0.1", port=5060), "sip:alice@127.0.0.1:5060", ), ( - SipUri( + SipURI( scheme="sip", user="alice", host=ipaddress.IPv4Address("127.0.0.1"), @@ -211,7 +211,7 @@ def test_parse_invalid(self, uri_str): ), # password in user-info ( - SipUri( + SipURI( scheme="sip", user="alice", password="secret", # noqa: S106 @@ -222,7 +222,7 @@ def test_parse_invalid(self, uri_str): ), # flag URI parameter (value=None) in __str__ ( - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -243,7 +243,7 @@ def test_str(self, uri_obj, expected_uri_str): # flag URI parameter (;lr with no value) ( "sip:alice@example.com;lr", - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -254,7 +254,7 @@ def test_str(self, uri_obj, expected_uri_str): # header without '=' value ( "sip:alice@example.com?Subject", - SipUri( + SipURI( scheme="sip", user="alice", host="example.com", @@ -268,46 +268,46 @@ def test_parse__flag_parameter_and_valueless_header( self, uri_str, expected_uri_obj ): """Parse flag URI parameters and valueless headers.""" - assert SipUri.parse(uri_str) == expected_uri_obj + assert SipURI.parse(uri_str) == expected_uri_obj def test_maddr__with_parameter(self): """Parse NetworkAddress from maddr URI parameter.""" - uri = SipUri.parse("sip:alice@example.com;maddr=192.0.2.1:5060") + uri = SipURI.parse("sip:alice@example.com;maddr=192.0.2.1:5060") assert uri.maddr == (ipaddress.IPv4Address("192.0.2.1"), 5060) def test_maddr__without_parameter(self): """Fall back to host:port when maddr parameter is absent.""" - uri = SipUri.parse("sip:alice@192.0.2.2:5060") + uri = SipURI.parse("sip:alice@192.0.2.2:5060") result = uri.maddr assert result.port == 5060 def test_ttl__returns_value(self): """Return the ttl URI parameter value as a string.""" - uri = SipUri.parse("sip:alice@example.com;ttl=30") + uri = SipURI.parse("sip:alice@example.com;ttl=30") assert uri.ttl == 30 def test_ttl__absent(self): """Return None when the ttl parameter is absent.""" - assert SipUri.parse("sip:alice@example.com").ttl is None + assert SipURI.parse("sip:alice@example.com").ttl is None def test_transport__sips_returns_tls(self): """Return 'TLS' for sips: URIs that have no transport parameter.""" - uri = SipUri.parse("sips:alice@example.com") + uri = SipURI.parse("sips:alice@example.com") assert uri.transport == "TLS" def test_transport__sip_without_parameter_returns_none(self): """Return None for a plain sip: URI without transport parameter.""" - uri = SipUri.parse("sip:alice@example.com") + uri = SipURI.parse("sip:alice@example.com") assert uri.transport == "TLS" def test_transport__explicit_parameter(self): """Return explicit transport parameter value.""" - uri = SipUri.parse("sip:alice@example.com;transport=udp") + uri = SipURI.parse("sip:alice@example.com;transport=udp") assert uri.transport == "UDP" def test_isinstance__str(self): """SipUri instances are also plain str instances.""" - assert isinstance(SipUri.parse("sip:alice@example.com"), str) + assert isinstance(SipURI.parse("sip:alice@example.com"), str) class TestTelUri: @@ -321,19 +321,19 @@ class TestTelUri: ) def test_parse__valid(self, uri_str, number, is_global): """Parse number and global flag from a valid tel: URI.""" - uri = TelUri.parse(uri_str) + uri = TelURI.parse(uri_str) assert uri.number == number assert uri.is_global is is_global def test_parse__with_phone_context(self): """Parse phone-context parameter from a local tel: URI.""" - uri = TelUri.parse("tel:1234;phone-context=example.com") + uri = TelURI.parse("tel:1234;phone-context=example.com") assert uri.number == "1234" assert uri.phone_context == "example.com" def test_parse__phone_context_absent(self): """Return None for phone_context when the parameter is absent.""" - assert TelUri.parse("tel:+15551234567").phone_context is None + assert TelURI.parse("tel:+15551234567").phone_context is None @pytest.mark.parametrize( "uri_str", @@ -346,22 +346,22 @@ def test_parse__phone_context_absent(self): def test_parse__invalid(self, uri_str): """Raise ValueError when parsing an invalid tel: URI.""" with pytest.raises(ValueError): - TelUri.parse(uri_str) + TelURI.parse(uri_str) def test_str__global_number(self): """Canonical string equals the original tel: URI for a global number.""" - assert str(TelUri.parse("tel:+15551234567")) == "tel:+15551234567" + assert str(TelURI.parse("tel:+15551234567")) == "tel:+15551234567" def test_str__with_parameters(self): """Canonical string includes parameters.""" assert ( - str(TelUri.parse("tel:1234;phone-context=example.com")) + str(TelURI.parse("tel:1234;phone-context=example.com")) == "tel:1234;phone-context=example.com" ) def test_isinstance__str(self): """TelUri instances are also plain str instances.""" - assert isinstance(TelUri.parse("tel:+15551234567"), str) + assert isinstance(TelURI.parse("tel:+15551234567"), str) def _ok() -> Response: @@ -434,17 +434,17 @@ def test_repr__no_host(self): def test_uri__sip(self): """Extract a SipUri from a SIP CallerID.""" - assert isinstance(CallerID("sip:alice@example.com").uri, SipUri) + assert isinstance(CallerID("sip:alice@example.com").uri, SipURI) def test_uri__sip_angle_brackets(self): """Extract SipUri from a CallerID with angle-bracket notation.""" assert isinstance( - CallerID('"Alice" ;tag=abc').uri, SipUri + CallerID('"Alice" ;tag=abc').uri, SipURI ) def test_uri__tel(self): """Extract a TelUri from a tel: CallerID.""" - assert isinstance(CallerID("tel:+15551234567").uri, TelUri) + assert isinstance(CallerID("tel:+15551234567").uri, TelURI) def test_uri__absent(self): """Return None when no URI is present.""" diff --git a/voip/__main__.py b/voip/__main__.py index 7ba9372..4db22ce 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -12,7 +12,7 @@ from voip.rtp import RealtimeTransportProtocol, Session from voip.sip import dialog, messages from voip.sip.protocol import SessionInitiationProtocol -from voip.sip.types import SipUri +from voip.sip.types import SipURI from voip.types import NetworkAddress try: @@ -116,7 +116,7 @@ def sip(ctx, aor, stun_server, no_verify_tls): """Session Initiation Protocol (SIP).""" ctx.ensure_object(dict) try: - parsed_aor = SipUri.parse(aor) + parsed_aor = SipURI.parse(aor) except ValueError as exc: raise click.BadParameter(str(exc), param_hint="AOR") from exc @@ -200,9 +200,9 @@ async def _connect_sip_once( def _make_outbound_factory( *, verbose: int, - aor: SipUri, + aor: SipURI, rtp_protocol: RealtimeTransportProtocol, - target_uri: SipUri, + target_uri: SipURI, session_class: type[Session], session_kwargs: dict, ) -> collections.abc.Callable[[], ConsoleMessageProtocol]: @@ -237,11 +237,11 @@ def factory() -> ConsoleMessageProtocol: return factory -def _parse_dial_target(dial: str | None) -> SipUri | None: +def _parse_dial_target(dial: str | None) -> SipURI | None: if dial is None: return None try: - return SipUri.parse(dial) + return SipURI.parse(dial) except ValueError as exc: raise click.BadParameter(str(exc), param_hint="--dial") from exc @@ -541,7 +541,7 @@ def say(ctx, target: str, prompt: str, voice: str): aor = obj["aor"] try: - target_uri = SipUri.parse(target) + target_uri = SipURI.parse(target) except ValueError as exc: raise click.BadParameter(str(exc), param_hint="TARGET") from exc diff --git a/voip/sip/__init__.py b/voip/sip/__init__.py index 42e3821..3548e7c 100644 --- a/voip/sip/__init__.py +++ b/voip/sip/__init__.py @@ -7,12 +7,12 @@ from .dialog import Dialog from .messages import Message, Request, Response from .protocol import SessionInitiationProtocol -from .types import CallerID, SIPMethod, SIPStatus, SipUri, TelUri +from .types import CallerID, SIPMethod, SIPStatus, SipURI, TelURI __all__ = [ "CallerID", - "SipUri", - "TelUri", + "SipURI", + "TelURI", "SIPStatus", "SIPMethod", "Message", diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index 41cac28..7c7a645 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -7,7 +7,7 @@ import uuid from voip.sip import messages, transactions, types -from voip.sip.types import SipUri +from voip.sip.types import SipURI if typing.TYPE_CHECKING: from voip.rtp import Session @@ -52,7 +52,7 @@ class MySession(SessionInitiationProtocol): # https://datatracker.ietf.org/doc/html/rfc3261#section-17.1.2 BYE_ACK_TIMEOUT: typing.ClassVar[datetime.timedelta] = 64 * T1 - uac: SipUri = None + uac: SipURI = None call_id: str = dataclasses.field( default_factory=lambda: f"{uuid.uuid4()}@{socket.gethostname()}", compare=False, @@ -61,8 +61,8 @@ class MySession(SessionInitiationProtocol): default_factory=lambda: str(uuid.uuid4()), compare=True ) remote_tag: str | None = dataclasses.field(default=None, compare=True) - remote_contact: SipUri | None = dataclasses.field(default=None, compare=True) - route_set: list[SipUri] = dataclasses.field(default_factory=list) + remote_contact: SipURI | None = dataclasses.field(default=None, compare=True) + route_set: list[SipURI] = dataclasses.field(default_factory=list) local_party: str | None = dataclasses.field(default=None, compare=False) remote_party: str | None = dataclasses.field(default=None, compare=False) outbound_cseq: int = dataclasses.field(default=1, compare=False) @@ -177,7 +177,7 @@ async def bye(self) -> None: async def dial( self, - target: SipUri, + target: SipURI, *, session_class: type[Session], **session_kwargs: typing.Any, diff --git a/voip/sip/messages.py b/voip/sip/messages.py index ce05a41..1a1dd9d 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -10,7 +10,7 @@ from voip.sdp.messages import SessionDescription from ..types import ByteSerializableObject -from .types import CallerID, SIPMethod, SIPStatus, SipUri +from .types import CallerID, SIPMethod, SIPStatus, SipURI if typing.TYPE_CHECKING: from voip.sip.dialog import Dialog @@ -105,7 +105,7 @@ def __bytes__(self) -> bytes: def branch(self) -> str | None: """Branch parameter from the top Via header (RFC 3261 §20.42).""" _, uri = self.headers["Via"].split() - return SipUri.parse(f"sip:{uri}").parameters["branch"] + return SipURI.parse(f"sip:{uri}").parameters["branch"] @property def remote_tag(self) -> str | None: @@ -135,7 +135,7 @@ class Request(Message): """ method: SIPMethod | str - uri: SipUri | str + uri: SipURI | str def _first_line(self) -> str: return f"{self.method} {self.uri} {self.version}" diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 491d7a5..3299736 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -95,7 +95,7 @@ async def main(): """ - aor: types.SipUri + aor: types.SipURI rtp: RealtimeTransportProtocol dialog_class: type[Dialog] = dataclasses.field(default=Dialog) keepalive_interval: datetime.timedelta = datetime.timedelta(seconds=30) diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 988b003..ce0ec9e 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -182,7 +182,7 @@ def __post_init__(self): self.request = Request.from_dialog( dialog=self.dialog, method=SIPMethod.REGISTER, - uri=types.SipUri(host=self.sip.aor.host, scheme=self.sip.aor.scheme), + uri=types.SipURI(host=self.sip.aor.host, scheme=self.sip.aor.scheme), headers=headers, ) @@ -626,7 +626,7 @@ async def send( cls, *, sip: SessionInitiationProtocol, - target: types.SipUri, + target: types.SipURI, dialog: Dialog, session_class: type[Session], **session_kwargs: typing.Any, diff --git a/voip/sip/types.py b/voip/sip/types.py index cc1983e..b141a03 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -6,8 +6,8 @@ "CallerID", "DigestAlgorithm", "DigestQoP", - "SipUri", - "TelUri", + "SipURI", + "TelURI", "SIPStatus", "SIPMethod", ] @@ -22,7 +22,7 @@ pass -class SipUri(str): +class SipURI(str): """A parsed SIP or SIPS URI per [RFC 3261 §19.1]. Format: ``sip:user:password@host:port;uri-parameters?headers`` @@ -38,11 +38,11 @@ class SipUri(str): [RFC 2732]: https://datatracker.ietf.org/doc/html/rfc2732 Examples: - >>> SipUri.parse("sip:alice@example.com") + >>> SipURI.parse("sip:alice@example.com") 'sip:alice@example.com:5060' - >>> SipUri.parse("sips:+15551234567@carrier.com:5061") + >>> SipURI.parse("sips:+15551234567@carrier.com:5061") 'sips:%2B15551234567@carrier.com:5061' - >>> SipUri.parse("sip:alice@[::1]:5060") + >>> SipURI.parse("sip:alice@[::1]:5060") 'sip:alice@[::1]:5060' Args: @@ -85,7 +85,7 @@ def __new__( port: int | None = None, parameters: dict[str, str | None] | None = None, headers: dict[str, str] | None = None, - ) -> SipUri: + ) -> SipURI: try: host = ipaddress.ip_address(host) except ValueError: @@ -128,7 +128,7 @@ def __new__( return instance @classmethod - def parse(cls, value: str) -> SipUri: + def parse(cls, value: str) -> SipURI: """ Parse a SIP or SIPS URI string into a `SipUri` instance. @@ -205,7 +205,7 @@ def transport(self): ) -class TelUri(str): +class TelURI(str): """A tel: URI per [RFC 3966]. Format: ``tel:phone-number;parameters`` @@ -217,9 +217,9 @@ class TelUri(str): [RFC 3966]: https://datatracker.ietf.org/doc/html/rfc3966 Examples: - >>> TelUri.parse("tel:+15551234567") + >>> TelURI.parse("tel:+15551234567") 'tel:+15551234567' - >>> TelUri.parse("tel:1234;phone-context=example.com") + >>> TelURI.parse("tel:1234;phone-context=example.com") 'tel:1234;phone-context=example.com' Args: @@ -243,7 +243,7 @@ def __new__( cls, number: str, parameters: dict[str, str | None] | None = None, - ) -> TelUri: + ) -> TelURI: parameters = parameters or {} parts = [f"tel:{number}"] for name, val in parameters.items(): @@ -258,7 +258,7 @@ def __new__( return instance @classmethod - def parse(cls, value: str) -> TelUri: + def parse(cls, value: str) -> TelURI: """Parse a tel: URI string into a `TelUri` instance. Returns: @@ -270,7 +270,7 @@ def parse(cls, value: str) -> TelUri: if match := cls.TEL_URL_PATTERN.fullmatch(value): return cls( number=match.group("number"), - parameters=dict(SipUri._parse_parameters(match.group("parameters"))) + parameters=dict(SipURI._parse_parameters(match.group("parameters"))) if match.group("parameters") else {}, ) @@ -312,17 +312,17 @@ def display_name(self) -> str | None: return None @property - def uri(self) -> SipUri | TelUri | None: + def uri(self) -> SipURI | TelURI | None: """Parsed SIP or tel URI embedded in the header value, if present.""" if not (m := re.search(r"\s]+)>?", self)): return None raw = m.group(1) try: - return SipUri.parse(raw) + return SipURI.parse(raw) except ValueError: pass try: - return TelUri.parse(raw) + return TelURI.parse(raw) except ValueError: return None @@ -330,9 +330,9 @@ def uri(self) -> SipUri | TelUri | None: def user(self) -> str | None: """SIP user part or telephone number.""" match self.uri: - case SipUri() as sip: + case SipURI() as sip: return sip.user - case TelUri() as tel: + case TelURI() as tel: return tel.number case _: return None @@ -341,7 +341,7 @@ def user(self) -> str | None: def host(self) -> str | None: """Carrier domain extracted from the SIP URI.""" match self.uri: - case SipUri() as sip: + case SipURI() as sip: return str(sip.host) case _: return None From 7f5216c53eb8e38561119b0f469033a426cf4c0b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:45:10 +0000 Subject: [PATCH 27/45] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_rtp.py | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tests/test_rtp.py b/tests/test_rtp.py index 4d4f732..839e97d 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -162,7 +162,9 @@ def packet_received(self, packet: RTPPacket, addr): routed.append(packet) mux = RealtimeTransportProtocol() - handler = RecordCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) + handler = RecordCall( + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") + ) remote_addr = ("127.0.0.1", 5004) mux.register_call(remote_addr, handler) rtp_packet = make_rtp_packet(payload=b"audio") @@ -187,7 +189,9 @@ def packet_received(self, packet: RTPPacket, addr): routed.append(packet) mux = RealtimeTransportProtocol() - handler = RecordCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) + handler = RecordCall( + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") + ) mux.register_call(("127.0.0.1", 5004), handler) # 5 bytes is shorter than the 12-byte minimum RTP header — must not raise. mux.datagram_received(b"\x80\x00\x00\x01\x00", ("127.0.0.1", 5004)) @@ -203,7 +207,9 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() mux.connection_made(MagicMock(spec=asyncio.DatagramTransport)) - handler = RecordCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) + handler = RecordCall( + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") + ) mux.register_call(None, handler) stun_bytes = b"\x01\x01" + b"\x00" * 18 # first byte = 1 (STUN range [0,3]) mux.datagram_received(stun_bytes, ("127.0.0.1", 5004)) @@ -312,7 +318,9 @@ def packet_received(self, packet: RTPPacket, addr): received.append(packet) mux = RealtimeTransportProtocol() - handler = WildcardCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) + handler = WildcardCall( + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") + ) mux.register_call(None, handler) rtp_packet = make_rtp_packet(payload=b"unmatched") @@ -330,7 +338,9 @@ def packet_received(self, packet: RTPPacket, addr): received.append(packet) mux = RealtimeTransportProtocol() - handler = RecordCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) + handler = RecordCall( + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") + ) remote_addr = ("5.6.7.8", 5004) mux.register_call(remote_addr, handler) mux.unregister_call(remote_addr) @@ -344,7 +354,9 @@ async def test_register_call__logs_info(self, caplog): import logging # noqa: PLC0415 mux = RealtimeTransportProtocol() - handler = Session(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) + handler = Session( + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") + ) with caplog.at_level(logging.INFO, logger="voip.rtp"): mux.register_call(("1.2.3.4", 5004), handler) assert any("rtp_call_registered" in r.message for r in caplog.records) @@ -355,7 +367,9 @@ async def test_unregister_call__logs_info(self, caplog): import logging # noqa: PLC0415 mux = RealtimeTransportProtocol() - handler = Session(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) + handler = Session( + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") + ) addr = ("1.2.3.4", 5004) mux.register_call(addr, handler) with caplog.at_level(logging.INFO, logger="voip.rtp"): @@ -372,7 +386,9 @@ def packet_received(self, packet: RTPPacket, addr): received.append((packet, addr)) mux = RealtimeTransportProtocol() - handler = CapturingCall(rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("")) + handler = CapturingCall( + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") + ) mux.register_call(None, handler) packet = make_rtp_packet() mux.packet_received(packet, ("1.2.3.4", 5004)) @@ -483,7 +499,9 @@ def test_media__stored_on_instance(self): def test_rtp_stored_as_field(self): """Rtp back-reference is stored on the instance.""" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) - call = Session(rtp=mock_rtp, dialog=Dialog(), media=make_media(), caller=CallerID("")) + call = Session( + rtp=mock_rtp, dialog=Dialog(), media=make_media(), caller=CallerID("") + ) assert call.rtp is mock_rtp def test_packet_received__noop_by_default(self): From b1610c44e515f6976b228789a34b4c002b4dce21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:27:45 +0000 Subject: [PATCH 28/45] Support TelURI as dial target in Dialog.dial(), InviteTransaction.send(), and CLI Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/c9728ee6-5b5d-46b6-88ef-c4ad13dc1462 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- voip/__main__.py | 19 +++++++++++++------ voip/sip/dialog.py | 6 +++--- voip/sip/transactions.py | 4 ++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/voip/__main__.py b/voip/__main__.py index 4db22ce..6c69ca9 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -12,7 +12,7 @@ from voip.rtp import RealtimeTransportProtocol, Session from voip.sip import dialog, messages from voip.sip.protocol import SessionInitiationProtocol -from voip.sip.types import SipURI +from voip.sip.types import SipURI, TelURI from voip.types import NetworkAddress try: @@ -202,7 +202,7 @@ def _make_outbound_factory( verbose: int, aor: SipURI, rtp_protocol: RealtimeTransportProtocol, - target_uri: SipURI, + target_uri: SipURI | TelURI, session_class: type[Session], session_kwargs: dict, ) -> collections.abc.Callable[[], ConsoleMessageProtocol]: @@ -237,11 +237,15 @@ def factory() -> ConsoleMessageProtocol: return factory -def _parse_dial_target(dial: str | None) -> SipURI | None: +def _parse_dial_target(dial: str | None) -> SipURI | TelURI | None: if dial is None: return None try: return SipURI.parse(dial) + except ValueError: + pass + try: + return TelURI.parse(dial) except ValueError as exc: raise click.BadParameter(str(exc), param_hint="--dial") from exc @@ -541,9 +545,12 @@ def say(ctx, target: str, prompt: str, voice: str): aor = obj["aor"] try: - target_uri = SipURI.parse(target) - except ValueError as exc: - raise click.BadParameter(str(exc), param_hint="TARGET") from exc + target_uri: SipURI | TelURI = SipURI.parse(target) + except ValueError: + try: + target_uri = TelURI.parse(target) + except ValueError as exc: + raise click.BadParameter(str(exc), param_hint="TARGET") from exc async def run(): _, rtp_protocol = await _connect_rtp( diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index 7c7a645..ae47e91 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -7,7 +7,7 @@ import uuid from voip.sip import messages, transactions, types -from voip.sip.types import SipURI +from voip.sip.types import SipURI, TelURI if typing.TYPE_CHECKING: from voip.rtp import Session @@ -177,7 +177,7 @@ async def bye(self) -> None: async def dial( self, - target: SipURI, + target: SipURI | TelURI, *, session_class: type[Session], **session_kwargs: typing.Any, @@ -186,7 +186,7 @@ async def dial( Initiate an outbound call to *target*. Args: - target: SIP URI of the remote party (e.g. ``"sip:+15551234567@carrier.com"``). + target: SIP or tel URI of the remote party (e.g. ``"sip:+15551234567@carrier.com"`` or ``"tel:+15551234567"``). session_class: Session subclass to create for this call. **session_kwargs: Extra keyword arguments forwarded to `session_class`. diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index ce0ec9e..0c1325c 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -626,7 +626,7 @@ async def send( cls, *, sip: SessionInitiationProtocol, - target: types.SipURI, + target: types.SipURI | types.TelURI, dialog: Dialog, session_class: type[Session], **session_kwargs: typing.Any, @@ -635,7 +635,7 @@ async def send( Args: sip: The SIP session to send from. - target: SIP URI of the callee (e.g. ``"sip:+15551234567@carrier.com"``). + target: SIP or tel URI of the callee (e.g. ``"sip:+15551234567@carrier.com"`` or ``"tel:+15551234567"``). dialog: The dialog to associate with this call. session_class: Session implementation that will be initialized for the call. **session_kwargs: Additional keyword arguments forwarded to the From 0e1df3d066225a138afb2e678df738a2b0196826 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:23:29 +0000 Subject: [PATCH 29/45] Convert TelURI to SipURI in InviteTransaction.send() using AOR host Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/bb80fb17-83ca-4e9b-8f93-183a13ed2b8c Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- voip/sip/transactions.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 0c1325c..0c37822 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -646,6 +646,12 @@ async def send( [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 """ + if isinstance(target, types.TelURI): + target = types.SipURI( + scheme=sip.aor.scheme, + host=sip.aor.host, + user=target.number, + ) if dialog.uac is None: dialog.uac = sip.aor dialog.sip = sip From bf288973a77e0ebdc3a34e28702090281643ca61 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Fri, 3 Apr 2026 12:08:32 +0200 Subject: [PATCH 30/45] fixes --- voip/__main__.py | 5 +- voip/sip/transactions.py | 4 +- voip/sip/types.py | 164 +++++++++++++++++++-------------------- 3 files changed, 86 insertions(+), 87 deletions(-) diff --git a/voip/__main__.py b/voip/__main__.py index 6c69ca9..144a7b6 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -206,7 +206,6 @@ def _make_outbound_factory( session_class: type[Session], session_kwargs: dict, ) -> collections.abc.Callable[[], ConsoleMessageProtocol]: - target = str(target_uri) class OutboundDialog(dialog.Dialog): def hangup_received(self) -> None: @@ -215,7 +214,7 @@ def hangup_received(self) -> None: @dataclasses.dataclass(kw_only=True, slots=True) class OutboundProtocol(ConsoleMessageProtocol): - dial_target: str + dial_target: SipURI | TelURI def on_registered(self) -> None: dialog = OutboundDialog(sip=self) @@ -231,7 +230,7 @@ def factory() -> ConsoleMessageProtocol: dialog_class=OutboundDialog, aor=aor, rtp=rtp_protocol, - dial_target=target, + dial_target=target_uri, ) return factory diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 0c37822..b61f2a8 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -829,7 +829,7 @@ async def _accept_call(self, response: Response) -> None: ack_branch = f"{Transaction.branch_prefix}-{uuid.uuid4()}" contact = response.headers.get("Contact") ack_uri = ( - contact.strip("<>").split(";")[0] if contact else str(self.request.uri) + contact.split(";")[0].strip("<>") if contact else str(self.request.uri) ) # Store BYE-ready dialog state now that dialog tags are finalised. @@ -852,7 +852,7 @@ async def _accept_call(self, response: Response) -> None: "To": response.headers["To"], "Call-ID": self.dialog.call_id, "CSeq": f"{self.cseq} {SIPMethod.ACK}", - "Content-Length": 0, + "Content-Length": "0", } ) for route in self.dialog.route_set: diff --git a/voip/sip/types.py b/voip/sip/types.py index b141a03..ef89f69 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -22,6 +22,88 @@ pass +class TelURI(str): + """A tel: URI per [RFC 3966]. + + Format: ``tel:phone-number;parameters`` + + Behaves as a plain ``str`` holding the canonical URI. Global numbers + (E.164) start with ``+``. Local numbers carry a ``phone-context`` + parameter identifying the dialling context. + + [RFC 3966]: https://datatracker.ietf.org/doc/html/rfc3966 + + Examples: + >>> TelURI.parse("tel:+15551234567") + 'tel:+15551234567' + >>> TelURI.parse("tel:1234;phone-context=example.com") + 'tel:1234;phone-context=example.com' + + Args: + number: The phone number, including any visual separators. + parameters: URI parameters as a mapping of name → value (`None` for flag parameters). + + """ + + __slots__ = ("number", "parameters") + + number: str + parameters: dict[str, str | None] + + TEL_URL_PATTERN: typing.ClassVar[re.Pattern[str]] = re.compile( + r"^tel:(?P[+0-9A-F*#().,-]+)" + r"(?P;.*)?$", + re.IGNORECASE, + ) + + def __new__( + cls, + number: str, + parameters: dict[str, str | None] | None = None, + ) -> TelURI: + parameters = parameters or {} + parts = [f"tel:{number}"] + for name, val in parameters.items(): + parts.append( + f";{urllib.parse.quote(name)}={urllib.parse.quote(val)}" + if val is not None + else f";{urllib.parse.quote(name)}" + ) + instance = super().__new__(cls, "".join(parts)) + instance.number = number + instance.parameters = parameters + return instance + + @classmethod + def parse(cls, value: str) -> TelURI: + """Parse a tel: URI string into a `TelUri` instance. + + Returns: + Parsed `TelUri` instance. + + Raises: + ValueError: When the URI is malformed or uses an unsupported scheme. + """ + if match := cls.TEL_URL_PATTERN.fullmatch(value): + return cls( + number=match.group("number"), + parameters=dict(SipURI._parse_parameters(match.group("parameters"))) + if match.group("parameters") + else {}, + ) + raise ValueError(f"Invalid tel URI: {value!r}") + + @property + def is_global(self) -> bool: + """Whether this is a global (E.164) number, starting with `+`.""" + return self.number.startswith("+") + + @property + def phone_context(self) -> str | None: + """The `phone-context` parameter value, if present.""" + return self.parameters.get("phone-context") + + class SipURI(str): """A parsed SIP or SIPS URI per [RFC 3261 §19.1]. @@ -205,88 +287,6 @@ def transport(self): ) -class TelURI(str): - """A tel: URI per [RFC 3966]. - - Format: ``tel:phone-number;parameters`` - - Behaves as a plain ``str`` holding the canonical URI. Global numbers - (E.164) start with ``+``. Local numbers carry a ``phone-context`` - parameter identifying the dialling context. - - [RFC 3966]: https://datatracker.ietf.org/doc/html/rfc3966 - - Examples: - >>> TelURI.parse("tel:+15551234567") - 'tel:+15551234567' - >>> TelURI.parse("tel:1234;phone-context=example.com") - 'tel:1234;phone-context=example.com' - - Args: - number: The phone number, including any visual separators. - parameters: URI parameters as a mapping of name → value (`None` for flag parameters). - - """ - - __slots__ = ("number", "parameters") - - number: str - parameters: dict[str, str | None] - - TEL_URL_PATTERN: typing.ClassVar[re.Pattern[str]] = re.compile( - r"^tel:(?P[+0-9A-F*#().,-]+)" - r"(?P;.*)?$", - re.IGNORECASE, - ) - - def __new__( - cls, - number: str, - parameters: dict[str, str | None] | None = None, - ) -> TelURI: - parameters = parameters or {} - parts = [f"tel:{number}"] - for name, val in parameters.items(): - parts.append( - f";{urllib.parse.quote(name)}={urllib.parse.quote(val)}" - if val is not None - else f";{urllib.parse.quote(name)}" - ) - instance = super().__new__(cls, "".join(parts)) - instance.number = number - instance.parameters = parameters - return instance - - @classmethod - def parse(cls, value: str) -> TelURI: - """Parse a tel: URI string into a `TelUri` instance. - - Returns: - Parsed `TelUri` instance. - - Raises: - ValueError: When the URI is malformed or uses an unsupported scheme. - """ - if match := cls.TEL_URL_PATTERN.fullmatch(value): - return cls( - number=match.group("number"), - parameters=dict(SipURI._parse_parameters(match.group("parameters"))) - if match.group("parameters") - else {}, - ) - raise ValueError(f"Invalid tel URI: {value!r}") - - @property - def is_global(self) -> bool: - """Whether this is a global (E.164) number, starting with `+`.""" - return self.number.startswith("+") - - @property - def phone_context(self) -> str | None: - """The `phone-context` parameter value, if present.""" - return self.parameters.get("phone-context") - - class CallerID(str): """SIP From/To header value with structured access and privacy-safe repr. From 70105960cd3047748f5f3c77659f5dbaa1bb1784 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Fri, 3 Apr 2026 12:35:00 +0200 Subject: [PATCH 31/45] update docs --- README.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b7ace65..7fb7f68 100644 --- a/README.md +++ b/README.md @@ -20,29 +20,31 @@ Async VoIP Python library for the AI age. ## Usage -### CLI +To get started, you will need a SIP account. One is usually included with ISP. +Check your ISP's documentation or router for details. -Answer calls and transcribe them live from the terminal: +You will need a SIP AOR (URI), which looks like this: -```console -uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe +```INI +sip:USER:PASSSWORD@SIP_SERVER;transport=TCP ``` -A simple echo server can be started with: +> [!NOTE] +> This library uses secure defaults (TLS transport on port 5061). +> However, most SIP servers only support unencrypted connections. +> Therefore, you will need to provide an explict transport parameter. -```console -uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo -``` +### CLI -Each command supports an optional `--dial TARGET` flag to initiate an -outbound call instead of waiting for an inbound one: +A simple echo call can be started with: ```console -uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo --dial sip:+15551234567@sip.example.com -uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe --dial sip:+15551234567@sip.example.com -uvx 'voip[cli]' sip sips:alice:********@sip.example.com agent --dial sip:+15551234567@sip.example.com --initial-prompt "Hello, how can I help you?" +uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo ``` +Each command supports an optional `--dial` argument to initiate an +outbound call instead of waiting for an inbound one. + To dial a number, say a message, and hang up automatically: ```console @@ -52,7 +54,7 @@ uvx 'voip[cli]' sip sips:alice:********@sip.example.com say sip:+15551234567@sip You can also talk to a local agent (needs [Ollama]): ```console -uvx 'voip[cli]' sip sips:alice:********@sip.example.com agent +uvx 'voip[cli]' sip sips:alice:********@sip.example.com agent --initial-prompt "Hi, I am looking for a Mr. Ron, first name Mo?" ``` ### Python API From 36de403bac5e0c2578cc78922d4d64fd93ff4c0b Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Fri, 3 Apr 2026 16:16:57 +0200 Subject: [PATCH 32/45] Drop tel URi --- docs/sip.md | 2 - tests/sip/test_types.py | 60 +----------------- voip/__main__.py | 44 +++----------- voip/sip/__init__.py | 3 +- voip/sip/dialog.py | 4 +- voip/sip/transactions.py | 8 +-- voip/sip/types.py | 127 ++++++--------------------------------- 7 files changed, 34 insertions(+), 214 deletions(-) diff --git a/docs/sip.md b/docs/sip.md index 4a81937..c568851 100644 --- a/docs/sip.md +++ b/docs/sip.md @@ -20,8 +20,6 @@ ::: voip.sip.SipURI -::: voip.sip.TelURI - ::: voip.sip.CallerID ::: voip.sip.SIPStatus diff --git a/tests/sip/test_types.py b/tests/sip/test_types.py index be81450..81a36af 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -1,7 +1,7 @@ import ipaddress import pytest -from voip.sip import SipURI, TelURI +from voip.sip import SipURI from voip.sip.messages import Response from voip.sip.types import CallerID @@ -310,60 +310,6 @@ def test_isinstance__str(self): assert isinstance(SipURI.parse("sip:alice@example.com"), str) -class TestTelUri: - @pytest.mark.parametrize( - "uri_str, number, is_global", - [ - ("tel:+15551234567", "+15551234567", True), - ("tel:1234", "1234", False), - ("tel:+1-202-555-0100", "+1-202-555-0100", True), - ], - ) - def test_parse__valid(self, uri_str, number, is_global): - """Parse number and global flag from a valid tel: URI.""" - uri = TelURI.parse(uri_str) - assert uri.number == number - assert uri.is_global is is_global - - def test_parse__with_phone_context(self): - """Parse phone-context parameter from a local tel: URI.""" - uri = TelURI.parse("tel:1234;phone-context=example.com") - assert uri.number == "1234" - assert uri.phone_context == "example.com" - - def test_parse__phone_context_absent(self): - """Return None for phone_context when the parameter is absent.""" - assert TelURI.parse("tel:+15551234567").phone_context is None - - @pytest.mark.parametrize( - "uri_str", - [ - "sip:alice@example.com", - "http://example.com", - "tel:", - ], - ) - def test_parse__invalid(self, uri_str): - """Raise ValueError when parsing an invalid tel: URI.""" - with pytest.raises(ValueError): - TelURI.parse(uri_str) - - def test_str__global_number(self): - """Canonical string equals the original tel: URI for a global number.""" - assert str(TelURI.parse("tel:+15551234567")) == "tel:+15551234567" - - def test_str__with_parameters(self): - """Canonical string includes parameters.""" - assert ( - str(TelURI.parse("tel:1234;phone-context=example.com")) - == "tel:1234;phone-context=example.com" - ) - - def test_isinstance__str(self): - """TelUri instances are also plain str instances.""" - assert isinstance(TelURI.parse("tel:+15551234567"), str) - - def _ok() -> Response: return Response(status_code=200, phrase="OK") @@ -442,10 +388,6 @@ def test_uri__sip_angle_brackets(self): CallerID('"Alice" ;tag=abc').uri, SipURI ) - def test_uri__tel(self): - """Extract a TelUri from a tel: CallerID.""" - assert isinstance(CallerID("tel:+15551234567").uri, TelURI) - def test_uri__absent(self): """Return None when no URI is present.""" assert CallerID("plain string").uri is None diff --git a/voip/__main__.py b/voip/__main__.py index 144a7b6..6f0394d 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -12,7 +12,7 @@ from voip.rtp import RealtimeTransportProtocol, Session from voip.sip import dialog, messages from voip.sip.protocol import SessionInitiationProtocol -from voip.sip.types import SipURI, TelURI +from voip.sip.types import SipURI, parse_uri from voip.types import NetworkAddress try: @@ -202,7 +202,7 @@ def _make_outbound_factory( verbose: int, aor: SipURI, rtp_protocol: RealtimeTransportProtocol, - target_uri: SipURI | TelURI, + target_uri: SipURI, session_class: type[Session], session_kwargs: dict, ) -> collections.abc.Callable[[], ConsoleMessageProtocol]: @@ -214,7 +214,7 @@ def hangup_received(self) -> None: @dataclasses.dataclass(kw_only=True, slots=True) class OutboundProtocol(ConsoleMessageProtocol): - dial_target: SipURI | TelURI + dial_target: SipURI def on_registered(self) -> None: dialog = OutboundDialog(sip=self) @@ -236,19 +236,6 @@ def factory() -> ConsoleMessageProtocol: return factory -def _parse_dial_target(dial: str | None) -> SipURI | TelURI | None: - if dial is None: - return None - try: - return SipURI.parse(dial) - except ValueError: - pass - try: - return TelURI.parse(dial) - except ValueError as exc: - raise click.BadParameter(str(exc), param_hint="--dial") from exc - - @sip.command() @click.option( "--dial", @@ -263,7 +250,6 @@ def echo(ctx, dial: str | None): obj = ctx.obj aor = obj["aor"] - target_uri = _parse_dial_target(dial) class EchoDialog(dialog.Dialog): def call_received(self) -> None: @@ -275,7 +261,7 @@ async def run(): aor.maddr, obj["stun_server"], ) - if target_uri is None: + if dial is None: await _connect_sip( lambda: ConsoleMessageProtocol( verbose=obj.get("verbose", 0), @@ -293,7 +279,7 @@ async def run(): verbose=obj.get("verbose", 0), aor=aor, rtp_protocol=rtp_protocol, - target_uri=target_uri, + target_uri=parse_uri(dial, aor), session_class=EchoCall, session_kwargs={}, ), @@ -331,7 +317,6 @@ def transcribe(ctx, stt_model, dial: str | None): obj = ctx.obj aor = obj["aor"] - target_uri = _parse_dial_target(dial) @dataclasses.dataclass(kw_only=True, slots=True) class TranscribingCall(TranscribeCall): @@ -353,7 +338,7 @@ async def run(): aor.maddr, obj["stun_server"], ) - if target_uri is None: + if dial is None: await _connect_sip( lambda: ConsoleMessageProtocol( verbose=obj.get("verbose", 0), @@ -371,7 +356,7 @@ async def run(): verbose=obj.get("verbose", 0), aor=aor, rtp_protocol=rtp_protocol, - target_uri=target_uri, + target_uri=parse_uri(dial, aor), session_class=TranscribingCall, session_kwargs={"stt_model": WhisperModel(stt_model)}, ), @@ -444,7 +429,6 @@ def agent( obj = ctx.obj aor = obj["aor"] - target_uri = _parse_dial_target(dial) @dataclasses.dataclass(kw_only=True, slots=True) class AgentCallWithOutput(AgentCall): @@ -488,7 +472,7 @@ async def run(): aor.maddr, obj["stun_server"], ) - if target_uri is None: + if dial is None: await _connect_sip( lambda: ConsoleMessageProtocol( verbose=obj.get("verbose", 0), @@ -506,7 +490,7 @@ async def run(): verbose=obj.get("verbose", 0), aor=aor, rtp_protocol=rtp_protocol, - target_uri=target_uri, + target_uri=parse_uri(dial, aor), session_class=AgentCallWithOutput, session_kwargs={ "stt_model": WhisperModel(stt_model), @@ -543,14 +527,6 @@ def say(ctx, target: str, prompt: str, voice: str): obj = ctx.obj aor = obj["aor"] - try: - target_uri: SipURI | TelURI = SipURI.parse(target) - except ValueError: - try: - target_uri = TelURI.parse(target) - except ValueError as exc: - raise click.BadParameter(str(exc), param_hint="TARGET") from exc - async def run(): _, rtp_protocol = await _connect_rtp( aor.maddr, @@ -561,7 +537,7 @@ async def run(): verbose=obj.get("verbose", 0), aor=aor, rtp_protocol=rtp_protocol, - target_uri=target_uri, + target_uri=parse_uri(target, aor), session_class=SayCall, session_kwargs={"text": prompt, "voice": voice}, ), diff --git a/voip/sip/__init__.py b/voip/sip/__init__.py index 3548e7c..e8fbbb2 100644 --- a/voip/sip/__init__.py +++ b/voip/sip/__init__.py @@ -7,12 +7,11 @@ from .dialog import Dialog from .messages import Message, Request, Response from .protocol import SessionInitiationProtocol -from .types import CallerID, SIPMethod, SIPStatus, SipURI, TelURI +from .types import CallerID, SIPMethod, SIPStatus, SipURI __all__ = [ "CallerID", "SipURI", - "TelURI", "SIPStatus", "SIPMethod", "Message", diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index ae47e91..4f6512b 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -7,7 +7,7 @@ import uuid from voip.sip import messages, transactions, types -from voip.sip.types import SipURI, TelURI +from voip.sip.types import SipURI if typing.TYPE_CHECKING: from voip.rtp import Session @@ -177,7 +177,7 @@ async def bye(self) -> None: async def dial( self, - target: SipURI | TelURI, + target: SipURI, *, session_class: type[Session], **session_kwargs: typing.Any, diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index b61f2a8..5afd340 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -626,7 +626,7 @@ async def send( cls, *, sip: SessionInitiationProtocol, - target: types.SipURI | types.TelURI, + target: types.SipURI, dialog: Dialog, session_class: type[Session], **session_kwargs: typing.Any, @@ -646,12 +646,6 @@ async def send( [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 """ - if isinstance(target, types.TelURI): - target = types.SipURI( - scheme=sip.aor.scheme, - host=sip.aor.host, - user=target.number, - ) if dialog.uac is None: dialog.uac = sip.aor dialog.sip = sip diff --git a/voip/sip/types.py b/voip/sip/types.py index ef89f69..5698935 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -7,9 +7,9 @@ "DigestAlgorithm", "DigestQoP", "SipURI", - "TelURI", "SIPStatus", "SIPMethod", + "parse_uri", ] import typing @@ -22,94 +22,24 @@ pass -class TelURI(str): - """A tel: URI per [RFC 3966]. - - Format: ``tel:phone-number;parameters`` - - Behaves as a plain ``str`` holding the canonical URI. Global numbers - (E.164) start with ``+``. Local numbers carry a ``phone-context`` - parameter identifying the dialling context. - - [RFC 3966]: https://datatracker.ietf.org/doc/html/rfc3966 - - Examples: - >>> TelURI.parse("tel:+15551234567") - 'tel:+15551234567' - >>> TelURI.parse("tel:1234;phone-context=example.com") - 'tel:1234;phone-context=example.com' - - Args: - number: The phone number, including any visual separators. - parameters: URI parameters as a mapping of name → value (`None` for flag parameters). - - """ - - __slots__ = ("number", "parameters") - - number: str - parameters: dict[str, str | None] - - TEL_URL_PATTERN: typing.ClassVar[re.Pattern[str]] = re.compile( - r"^tel:(?P[+0-9A-F*#().,-]+)" - r"(?P;.*)?$", - re.IGNORECASE, - ) - - def __new__( - cls, - number: str, - parameters: dict[str, str | None] | None = None, - ) -> TelURI: - parameters = parameters or {} - parts = [f"tel:{number}"] - for name, val in parameters.items(): - parts.append( - f";{urllib.parse.quote(name)}={urllib.parse.quote(val)}" - if val is not None - else f";{urllib.parse.quote(name)}" - ) - instance = super().__new__(cls, "".join(parts)) - instance.number = number - instance.parameters = parameters - return instance - - @classmethod - def parse(cls, value: str) -> TelURI: - """Parse a tel: URI string into a `TelUri` instance. - - Returns: - Parsed `TelUri` instance. - - Raises: - ValueError: When the URI is malformed or uses an unsupported scheme. - """ - if match := cls.TEL_URL_PATTERN.fullmatch(value): - return cls( - number=match.group("number"), - parameters=dict(SipURI._parse_parameters(match.group("parameters"))) - if match.group("parameters") - else {}, - ) - raise ValueError(f"Invalid tel URI: {value!r}") - - @property - def is_global(self) -> bool: - """Whether this is a global (E.164) number, starting with `+`.""" - return self.number.startswith("+") - - @property - def phone_context(self) -> str | None: - """The `phone-context` parameter value, if present.""" - return self.parameters.get("phone-context") +def parse_uri(uri: str, aor: SipURI) -> SipURI: + """Parse a SIP or tel URI string into a `SipURI` with the AOR as context.""" + scheme, body = uri.split(":", 1) + match scheme.lower(): + case "sip" | "sips": + return SipURI.parse(uri) + case "tel": + return SipURI.parse(f"sip:{body}@{aor.host};user=phone") + case _: + raise ValueError(f"Invalid URI scheme: {uri[:3].lower()}") class SipURI(str): """A parsed SIP or SIPS URI per [RFC 3261 §19.1]. - Format: ``sip:user:password@host:port;uri-parameters?headers`` + Format: `sip:user:password@host:port;uri-parameters?headers` - Behaves as a plain ``str`` holding the canonical URI, so instances can be + Behaves as a plain `str` holding the canonical URI, so instances can be stored in header dicts unchanged. The `parse` classmethod decodes a raw SIP URI string into structured fields. IPv6 addresses in the host part must be enclosed in square brackets per [RFC 2732] @@ -312,39 +242,20 @@ def display_name(self) -> str | None: return None @property - def uri(self) -> SipURI | TelURI | None: + def uri(self) -> SipURI | None: """Parsed SIP or tel URI embedded in the header value, if present.""" - if not (m := re.search(r"\s]+)>?", self)): - return None - raw = m.group(1) - try: - return SipURI.parse(raw) - except ValueError: - pass - try: - return TelURI.parse(raw) - except ValueError: - return None + if m := re.search(r"\s]+)>?", self): + return SipURI.parse(m.group(1)) @property def user(self) -> str | None: """SIP user part or telephone number.""" - match self.uri: - case SipURI() as sip: - return sip.user - case TelURI() as tel: - return tel.number - case _: - return None + return self.uri.user if self.uri else None @property - def host(self) -> str | None: + def host(self) -> str | ipaddress.IPv4Address | ipaddress.IPv6Address | None: """Carrier domain extracted from the SIP URI.""" - match self.uri: - case SipURI() as sip: - return str(sip.host) - case _: - return None + return self.uri.host if self.uri else None @property def tag(self) -> str | None: From 0b6ab3774b987fa50a08182704a901e031745cff Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Fri, 3 Apr 2026 18:14:49 +0200 Subject: [PATCH 33/45] Acknowledge all responses --- voip/sip/transactions.py | 57 +++++++++++++++------------------------- 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 5afd340..1f790c8 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -716,40 +716,21 @@ async def send( raise def response_received(self, response: Response) -> None: - """Handle responses to an outbound INVITE. - - Dispatches provisional (1xx), successful (2xx), and failure (4xx–6xx) - responses. On 200 OK the call setup is completed asynchronously via - `_accept_call`. - - Args: - response: The parsed SIP response. - """ + """Dispatch responses to an outbound INVITE.""" match response.status_code // 100: - case 1: + case 1: # trying/ringing pass - case 2: - try: - asyncio.get_running_loop().create_task(self._accept_call(response)) - except RuntimeError: - logger.debug( - "response_received called outside of an async context; " - "200 OK will not be processed" - ) - case _: - self.sip.transactions.pop(self.branch, None) - logger.warning( - "Outbound call failed: %s %s", - response.status_code, - response.phrase, - ) + case SIPStatus.OK: + self._start_call(response) + self.ack(response) + case _: # any other terminal response + self.ack(response) - async def _accept_call(self, response: Response) -> None: + def _start_call(self, response: Response) -> None: """Complete call setup after a 200 OK is received. Negotiates the codec from the remote SDP answer, creates the call - handler, registers it with the RTP mux, updates the dialog, and - sends the ACK. + handler, registers it with the RTP mux, updates the dialog. Args: response: The 200 OK SIP response containing the remote SDP answer. @@ -808,17 +789,22 @@ async def _accept_call(self, response: Response) -> None: if remote_rtp_address is not None: self.sip.rtp.send(b"\x00", remote_rtp_address) - # Update the dialog with remote tag from 200 OK then store it. - # The To-tag in the 200 OK is the callee's tag (remote). The From-tag - # is our original local tag, which must become dialog.remote_tag so - # that subsequent in-dialog BYE lookups (keyed by - # (request.remote_tag, request.local_tag) = (our_tag, callee_tag)) - # resolve correctly via `sip.dialogs[(dialog.remote_tag, dialog.local_tag)]`. + self.ack(response) + self.sip.transactions.pop(self.branch, None) + self.complete() + + def ack(self, response: Response) -> None: + """ + Send an ACK after receiving a terminal response. + + Establish a dialog if the response is 200 OK. + """ our_tag = response.local_tag callee_tag = response.remote_tag self.dialog.remote_tag = our_tag self.dialog.local_tag = callee_tag - self.sip.dialogs[(our_tag, callee_tag)] = self.dialog + if response.status == SIPStatus.OK: + self.sip.dialogs[(our_tag, callee_tag)] = self.dialog ack_branch = f"{Transaction.branch_prefix}-{uuid.uuid4()}" contact = response.headers.get("Contact") @@ -859,7 +845,6 @@ async def _accept_call(self, response: Response) -> None: ) ) self.sip.transactions.pop(self.branch, None) - self.complete() @dataclasses.dataclass(kw_only=True, slots=True) From 58beb316f21f58a5f4a840561859c4304d508053 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Fri, 3 Apr 2026 18:42:20 +0200 Subject: [PATCH 34/45] Fix tests --- mkdocs.yml | 1 - tests/sip/test_types.py | 7 ++----- voip/sip/types.py | 10 ++++++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 37c16eb..082f4bd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,7 +26,6 @@ plugins: python: load_external_modules: true options: - locale: en docstring_style: google show_root_heading: true heading_level: 3 diff --git a/tests/sip/test_types.py b/tests/sip/test_types.py index 81a36af..41d4b76 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -394,11 +394,8 @@ def test_uri__absent(self): def test_uri__unparseable(self): """Return None when the URI-like string is not valid for any parser.""" - assert CallerID("sip:@invalid").uri is None - - def test_user__tel_number(self): - """Return the tel number as user for a tel: CallerID.""" - assert CallerID("tel:+15551234567").user == "+15551234567" + with pytest.raises(ValueError): + assert CallerID("sip:@invalid").uri def test_host__tel_absent(self): """Return None for host when the CallerID is a tel URI.""" diff --git a/voip/sip/types.py b/voip/sip/types.py index 5698935..29cbe5a 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -249,13 +249,15 @@ def uri(self) -> SipURI | None: @property def user(self) -> str | None: - """SIP user part or telephone number.""" - return self.uri.user if self.uri else None + """SIP user part (phone number or username).""" + if m := re.search(r"sips?:([^@>;\s]+)@", self): + return m.group(1) @property - def host(self) -> str | ipaddress.IPv4Address | ipaddress.IPv6Address | None: + def host(self) -> str | None: """Carrier domain extracted from the SIP URI.""" - return self.uri.host if self.uri else None + if m := re.search(r"sips?:[^@>;\s]+@([^>;)\s,]+)", self): + return m.group(1) @property def tag(self) -> str | None: From b499392de80d40e84dc7b58f445788b4925345b7 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Fri, 3 Apr 2026 18:51:48 +0200 Subject: [PATCH 35/45] Fix docs --- docs/cookbook.md | 8 ++++---- docs/sessions.md | 2 +- docs/sip.md | 5 ++++- voip/rtp.py | 2 +- voip/sip/dialog.py | 6 +++--- voip/sip/protocol.py | 4 ++-- voip/sip/transactions.py | 10 +++++----- voip/sip/types.py | 17 ----------------- 8 files changed, 20 insertions(+), 34 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index 3f61a30..4eb3914 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -221,10 +221,10 @@ session = SIP( Every [Session][voip.rtp.Session] subclass exposes a [hang_up][voip.rtp.Session.hang_up] coroutine that sends a proper SIP BYE request (RFC 3261 §15) by delegating to -[Dialog.bye][voip.sip.dialog.Dialog.bye]. It deregisters the RTP +[Dialog.bye][voip.sip.Dialog.bye]. It deregisters the RTP handler and awaits the 200 OK acknowledgment before returning. -Override [Dialog.call_received][voip.sip.dialog.Dialog.call_received] +Override [Dialog.call_received][voip.sip.Dialog.call_received] to hook into the call lifecycle, and call `await self.hang_up()` from within the call class when you want to terminate: @@ -285,9 +285,9 @@ tear down the transport. ## Making Outbound Calls -Create a [Dialog][voip.sip.dialog.Dialog] subclass, set it as +Create a [Dialog][voip.sip.Dialog] subclass, set it as `dialog_class` on your SIP session, and call -[dial][voip.sip.dialog.Dialog.dial] from +[dial][voip.sip.Dialog.dial] from [on_registered]\[voip.sip.protocol.SessionInitiationProtocol.on_registered\]: ```python diff --git a/docs/sessions.md b/docs/sessions.md index 3737499..92f3dc9 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -1,7 +1,7 @@ # Multimedia Sessions [Session][voip.rtp.Session] and its subclasses handle the media exchange between call parties. -They are created by the [Dialog][voip.sip.dialog.Dialog] when a call is accepted or initiated. +They are created by the [Dialog][voip.sip.Dialog] when a call is accepted or initiated. Sessions can be audio, video, and more. However, this library currently only provides audio sessions via the [AudioCall][voip.audio.AudioCall] class. Video and other media types are fairly uncommon outside of consumer applications, and implementing them is on the roadmap but not yet a priority. diff --git a/docs/sip.md b/docs/sip.md index c568851..fdb3b27 100644 --- a/docs/sip.md +++ b/docs/sip.md @@ -7,9 +7,10 @@ - call_received - hangup_received - ringing - - accept + - answer - reject - dial + - bye ::: voip.sip.SessionInitiationProtocol options: @@ -23,3 +24,5 @@ ::: voip.sip.CallerID ::: voip.sip.SIPStatus + +::: voip.sdp.types diff --git a/voip/rtp.py b/voip/rtp.py index b43074f..13297ca 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -142,7 +142,7 @@ async def hang_up(self) -> None: Terminate the call by sending a SIP BYE request [RFC 3261 §15]. Deregisters this call from the RTP multiplexer, then delegates the - BYE signaling to [Dialog.bye][voip.sip.dialog.Dialog.bye], which + BYE signaling to [Dialog.bye][voip.sip.Dialog.bye], which constructs and sends the BYE request, removes the dialog from the SIP session's registry, and awaits the 200 OK acknowledgment. diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index 4f6512b..4d5a76f 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -103,9 +103,9 @@ def call_received(self) -> None: """ Called when an INVITE is received from the remote party. - Override in subclasses to [accept][voip.sip.dialog.Dialog.accept], - [ring][voip.sip.dialog.Dialog.ringing], - or [reject][voip.sip.dialog.Dialog.reject] the call. + Override in subclasses to [answer][voip.sip.Dialog.answer], + [ring][voip.sip.Dialog.ringing], + or [reject][voip.sip.Dialog.reject] the call. The base implementation rejects with a busy signal. """ # noqa: D401 diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 3299736..989da42 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -87,9 +87,9 @@ async def main(): Args: aor: SIP Address of Record (AOR) to register with the carrier. rtp: Shared RTP mux for call media. - dialog_class: [Dialog][voip.sip.dialog.Dialog] subclass used to + dialog_class: [Dialog][voip.sip.Dialog] subclass used to create dialogs for incoming calls. Defaults to the base - [Dialog][voip.sip.dialog.Dialog] which rejects all calls with + [Dialog][voip.sip.Dialog] which rejects all calls with ``486 Busy Here``. keepalive_interval: Keep-alive ping interval. Should be between 30 and 90 seconds. diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 1f790c8..227c50e 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -364,8 +364,8 @@ class InviteTransaction(Transaction): SIP layer creates one instance per incoming INVITE, keyed by Via branch (RFC 3261 §17.1.3). - For inbound call handling, subclass [Dialog][voip.sip.dialog.Dialog] - and override [call_received][voip.sip.dialog.Dialog.call_received]: + For inbound call handling, subclass [Dialog][voip.sip.Dialog] + and override [call_received][voip.sip.Dialog.call_received]: ```python class MyDialog(Dialog): @@ -484,8 +484,8 @@ def answer( """Answer the call by setting up RTP and sending 200 OK with SDP. Example: - Call from within [Dialog.call_received][voip.sip.dialog.Dialog.call_received] - via [Dialog.accept][voip.sip.dialog.Dialog.accept]: + Call from within [Dialog.call_received][voip.sip.Dialog.call_received] + via [Dialog.accept][voip.sip.Dialog.accept]: ```python class MyDialog(Dialog): @@ -920,7 +920,7 @@ async def receive( """Handle an incoming BYE from the remote party [RFC 3261 §15.1.2]. Sends 200 OK, removes the dialog, and notifies the application via - [hangup_received][voip.sip.dialog.Dialog.hangup_received]. + [hangup_received][voip.sip.Dialog.hangup_received]. Args: request: The incoming SIP BYE request. diff --git a/voip/sip/types.py b/voip/sip/types.py index 29cbe5a..0e8a025 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -57,27 +57,10 @@ class SipURI(str): >>> SipURI.parse("sip:alice@[::1]:5060") 'sip:alice@[::1]:5060' - Args: - scheme: URI scheme — `sip` or `sips`. - host: Host as a bare string — no brackets for IPv6 addresses. - user: SIP user part (phone number or username). - password: Optional password in the user-info component. - port: Port number. 5061 for `sips:` and 5060 for `sip:`. - parameters: URI parameters as a mapping of name → value (`None` for flag parameters). - headers: SIP headers as a mapping of name → value. - """ __slots__ = ("scheme", "host", "user", "password", "port", "parameters", "headers") - scheme: str - host: str | ipaddress.IPv6Address | ipaddress.IPv4Address - user: str | None - password: str | None - port: int - parameters: dict[str, str | None] - headers: dict[str, str] - SIP_URL_PATTERN: typing.ClassVar[re.Pattern[str]] = re.compile( r"^(?Psips?):" r"((?P[^@;:]+)(?P:[^@;]*)?@)?" From 3eaff4796d16fcbad37cd1b6b9d407d5c998bf25 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Fri, 3 Apr 2026 18:56:06 +0200 Subject: [PATCH 36/45] Set pre-commit python version to 3.14 --- .pre-commit-config.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 687494a..4c1759d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +default_language_version: + python: python3.14 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 From 35b7fd040b8c6061f9b94a25aabc355c937603cf Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Sat, 4 Apr 2026 18:54:15 +0200 Subject: [PATCH 37/45] Address some review comments --- README.md | 2 +- docs/cookbook.md | 4 ++-- tests/sip/test_messages.py | 13 ++++++++++++- tests/sip/test_types.py | 2 +- voip/__main__.py | 6 +++--- voip/rtp.py | 2 +- voip/sip/dialog.py | 4 ++-- voip/sip/messages.py | 11 ++++++++++- voip/sip/protocol.py | 3 ++- voip/sip/transactions.py | 11 +++++------ 10 files changed, 39 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 7fb7f68..cc874e1 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Check your ISP's documentation or router for details. You will need a SIP AOR (URI), which looks like this: ```INI -sip:USER:PASSSWORD@SIP_SERVER;transport=TCP +sip:USER:PASSWORD@SIP_SERVER;transport=TCP ``` > [!NOTE] diff --git a/docs/cookbook.md b/docs/cookbook.md index 4eb3914..2bc86d1 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -235,7 +235,7 @@ import ssl import numpy as np from voip.audio import AudioCall -from voip.sip.messages import Dialog +from voip.sip.dialog import Dialog from voip.sip.protocol import SIP @@ -295,7 +295,7 @@ import asyncio import ssl from voip.audio import AudioCall -from voip.sip.messages import Dialog +from voip.sip.dialog import Dialog from voip.sip.protocol import SIP diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index 9a6b777..32e795d 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -7,7 +7,7 @@ from voip.sip.types import SipURI -class TestHeaderMap: +class TestSIPHeaderDict: def test_init(self): """Initialize a HeaderMap with a dictionary of headers.""" headers = messages.SIPHeaderDict( @@ -41,6 +41,17 @@ def test__bytes__(self): b"From: Alice\r\nRoute: sip:proxy.example.com\r\nRoute: sip:example.com\r\n" ) + def test_parse(self): + """Parse headers from bytes.""" + data = b"From: Alice\r\nRoute: sip:proxy.example.com\r\nRoute: sip:example.com" + headers = messages.SIPHeaderDict.parse(data) + assert headers["From"] == "Alice" + assert headers.getlist("Route") == ["sip:proxy.example.com", "sip:example.com"] + + def test_parse__empty(self): + with pytest.raises(ValueError, match="Invalid header: ''"): + messages.SIPHeaderDict.parse(b"") + class TestMessage: def test_parse__request(self): diff --git a/tests/sip/test_types.py b/tests/sip/test_types.py index 41d4b76..f996c20 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -6,7 +6,7 @@ from voip.sip.types import CallerID -class TestSipUri: +class TestSipURI: @pytest.mark.parametrize( "uri_str, expected_uri_obj", [ diff --git a/voip/__main__.py b/voip/__main__.py index 6f0394d..e5766b8 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -38,17 +38,17 @@ class ConsoleMessageProtocol(SessionInitiationProtocol): verbose: int = 0 def request_received(self, request: messages.Request): - self.pprint(request) super().request_received(request) + self.pprint(request) def response_received(self, response: messages.Response): - self.pprint(response) super().response_received(response) + self.pprint(response) def send(self, message) -> None: """Send a message and print it to stdout.""" - self.pprint(message) super().send(message) + self.pprint(message) def pprint(self, msg): """Pretty print the message. diff --git a/voip/rtp.py b/voip/rtp.py index 13297ca..fdb99ad 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -19,7 +19,7 @@ from voip.types import ByteSerializableObject, NetworkAddress if TYPE_CHECKING: - from voip.sip.messages import Dialog + from voip.sip.dialog import Dialog from voip.sip.types import CallerID __all__ = ["RTP", "Session", "RTPPacket", "RTPPayloadType", "RealtimeTransportProtocol"] diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index 4d5a76f..f3138a1 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -120,7 +120,7 @@ def hangup_received(self) -> None: def ringing(self) -> None: """ - Send the report party a ringing signal. + Send a ringing signal to the remote party. This is optional but recommended for good user experience. If not called, the caller will hear silence until the call is accepted or rejected. @@ -169,7 +169,7 @@ async def bye(self) -> None: ) except TimeoutError: logger.warning( - "BYE for dialog %s was not acknowledged within %.0f s", + "BYE for dialog %s was not acknowledged within %r", self.call_id, self.BYE_ACK_TIMEOUT, ) diff --git a/voip/sip/messages.py b/voip/sip/messages.py index 1a1dd9d..44653b4 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -3,10 +3,12 @@ import abc import dataclasses import logging +import platform import typing from urllib3 import HTTPHeaderDict +import voip from voip.sdp.messages import SessionDescription from ..types import ByteSerializableObject @@ -22,6 +24,13 @@ #: Headers whose values are parsed as `CallerID` objects. CALLER_IDS_HEADERS = frozenset({"From", "To", "Route", "Record-Route", "Contact"}) +#: User-Agent header value to use in generated messages. +USER_AGENT = ( + f"VoIP/{voip.__version__}" + f" {platform.python_implementation()}/{platform.python_version()}" + f" {platform.system()}/{platform.platform()}" +) + class SIPHeaderDict(ByteSerializableObject, HTTPHeaderDict): """Header map for SIP messages, mapping header names to their values.""" @@ -35,7 +44,7 @@ def parse(cls, data: bytes) -> SIPHeaderDict: for line in data.decode().split("\r\n"): name, sep, value = line.partition(":") if not sep: - raise ValueError(f"Invalid header: {data!r}") + raise ValueError(f"Invalid header: {line!r}") name = name.strip() value = value.strip() self.add(name, CallerID(value) if name in CALLER_IDS_HEADERS else value) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 989da42..c488084 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -16,7 +16,7 @@ from ..types import NetworkAddress from . import types from .dialog import Dialog -from .messages import Message, Request, Response +from .messages import USER_AGENT, Message, Request, Response from .transactions import ( ByeTransaction, InviteTransaction, @@ -214,6 +214,7 @@ def _dispatch_frame(self, frame: memoryview | bytes) -> None: def send(self, message: Response | Request) -> None: """Serialize and send a SIP message over the TLS/TCP connection.""" logger.debug("Sending %r", message) + message.headers.setdefault("User-Agent", USER_AGENT) if self.transport is not None: self.transport.write(bytes(message)) diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 227c50e..3fdae2d 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -11,7 +11,6 @@ import typing import uuid -import voip from voip.rtp import Session from voip.sdp.messages import SessionDescription from voip.sdp.types import ( @@ -210,7 +209,7 @@ def response_received(self, response: Response) -> None: ) challenge_key = "Proxy-Authenticate" if is_proxy else "WWW-Authenticate" params = self.parse_auth_challenge( - response.headers[challenge_key] or "" + response.headers.get(challenge_key, "") ) realm = params.get("realm", "") nonce = params.get("nonce", "") @@ -426,6 +425,8 @@ def ack_received(self, request: Request) -> None: """ self.sip.transactions.pop(self.branch, None) self.complete() + + def cancel_received(self, request: Request) -> None: """Handle a CANCEL request for a pending INVITE. Args: @@ -702,7 +703,6 @@ async def send( "Call-ID": dialog.call_id, "Route": f"", "Allow": sip.allow_header, - "User-Agent": f"python/voip/{voip.__version__}", "Content-Type": "application/sdp", }, body=sdp_offer, @@ -720,7 +720,7 @@ def response_received(self, response: Response) -> None: match response.status_code // 100: case 1: # trying/ringing pass - case SIPStatus.OK: + case 2: # OK self._start_call(response) self.ack(response) case _: # any other terminal response @@ -803,7 +803,7 @@ def ack(self, response: Response) -> None: callee_tag = response.remote_tag self.dialog.remote_tag = our_tag self.dialog.local_tag = callee_tag - if response.status == SIPStatus.OK: + if response.status_code == SIPStatus.OK: self.sip.dialogs[(our_tag, callee_tag)] = self.dialog ack_branch = f"{Transaction.branch_prefix}-{uuid.uuid4()}" @@ -895,7 +895,6 @@ async def send( "To": dialog.remote_party, "Call-ID": dialog.call_id, "CSeq": f"{cseq} {SIPMethod.BYE}", - "User-Agent": f"python/voip/{voip.__version__}", "Content-Length": "0", } ) From c137ce43ef70cc58930fada261dd8d4a39410eba Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Sat, 4 Apr 2026 19:03:13 +0200 Subject: [PATCH 38/45] Use correct IPs --- voip/__main__.py | 4 ++-- voip/sip/protocol.py | 18 ++++++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/voip/__main__.py b/voip/__main__.py index e5766b8..e68207a 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -38,12 +38,12 @@ class ConsoleMessageProtocol(SessionInitiationProtocol): verbose: int = 0 def request_received(self, request: messages.Request): - super().request_received(request) self.pprint(request) + super().request_received(request) def response_received(self, response: messages.Response): - super().response_received(response) self.pprint(response) + super().response_received(response) def send(self, message) -> None: """Send a message and print it to stdout.""" diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index c488084..7c40fab 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -7,7 +7,6 @@ import asyncio import dataclasses import datetime -import ipaddress import logging import typing @@ -101,7 +100,7 @@ async def main(): keepalive_interval: datetime.timedelta = datetime.timedelta(seconds=30) keepalive_task: asyncio.Task | None = dataclasses.field(init=False, default=None) - local_address: NetworkAddress = dataclasses.field(init=False) + public_address: NetworkAddress = None dialogs: dict[tuple[str, str], Dialog] = dataclasses.field( init=False, default_factory=dict ) @@ -115,13 +114,12 @@ async def main(): is_secure: bool = dataclasses.field(init=False, default=False) recv_buffer: bytearray = dataclasses.field(init=False, default_factory=bytearray) + def __post_init__(self): + self.public_address = self.public_address or self.rtp.public_address + def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore[override] """Store the TLS/TCP transport and start RTP mux + carrier registration.""" self.transport = transport - # IPv6 sockets return a 4-tuple (host, port, flowinfo, scope_id); - # we only need the first two elements. - host, port = transport.get_extra_info("sockname")[:2] - self.local_address = NetworkAddress(ipaddress.ip_address(host), port) self.is_secure = transport.get_extra_info("ssl_object") is not None try: loop = asyncio.get_running_loop() @@ -137,7 +135,7 @@ async def send_keepalive(self) -> None: await asyncio.sleep(self.keepalive_interval.total_seconds()) if self.transport is None: return - logger.info("PING", extra={"addr": self.local_address}) + logger.info("PING", extra={"addr": self.public_address}) self.transport.write(PING) async def handle_registration(self, tx: RegistrationTransaction) -> None: @@ -192,7 +190,7 @@ def _dispatch_frame(self, frame: memoryview | bytes) -> None: elif frame == PING: logger.info("PING", extra={"addr": peer}) if self.transport: - logger.info("PONG", extra={"addr": self.local_address}) + logger.info("PONG", extra={"addr": self.public_address}) self.transport.write(PONG) else: match Message.parse(bytes(frame)): @@ -343,9 +341,9 @@ def contact(self) -> str: [RFC 5626 §5]: https://datatracker.ietf.org/doc/html/rfc5626#section-5 """ address = ( - f"{self.aor.user}@{self.local_address}" + f"{self.aor.user}@{self.public_address}" if self.aor.user - else str(self.local_address) + else str(self.public_address) ) ob_uri_param = ";ob" if self.aor.scheme == "sips": From 7c96eb623f5cb7ee15875f1d37544f9de661a5fb Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Sat, 4 Apr 2026 19:04:48 +0200 Subject: [PATCH 39/45] Fix address names --- voip/sip/transactions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 3fdae2d..9047b31 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -88,7 +88,7 @@ def __post_init__(self): def headers(self) -> dict[str, str]: """Return a dict of headers for this transaction.""" return { - "Via": f"SIP/2.0/{self.sip.aor.transport} {self.sip.rtp.public_address};rport;branch={self.branch}", + "Via": f"SIP/2.0/{self.sip.aor.transport} {self.sip.public_address};rport;branch={self.branch}", "CSeq": f"{self.cseq} {self.method}", } @@ -571,7 +571,7 @@ def call_received(self) -> None: record_route = self.request.headers.get("Record-Route") session_id = str(secrets.randbelow(2**32) + 1) - rtp_public = self.sip.rtp.public_address + rtp_public = self.sip.public_address sdp_media_attributes = [Attribute(name="sendrecv")] if srtp_session is not None: sdp_media_attributes.append( @@ -825,7 +825,7 @@ def ack(self, response: Response) -> None: { "Via": ( f"SIP/2.0/{self.sip.aor.transport}" - f" {self.sip.rtp.public_address};rport;branch={ack_branch};alias" + f" {self.sip.public_address};rport;branch={ack_branch};alias" ), "Max-Forwards": "70", "From": response.headers["From"], From e784760c4a4d524019f059920917db343adb17e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:21:25 +0000 Subject: [PATCH 40/45] refactor: move dialog/tx state to protocol; fix dialog tags, double ACK, ACK routing Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/ebac7b0c-c592-467a-ac54-cd951d1e241d Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- voip/sip/dialog.py | 6 +-- voip/sip/protocol.py | 36 +++++++++++++---- voip/sip/transactions.py | 83 +++++++++++++++++----------------------- 3 files changed, 67 insertions(+), 58 deletions(-) diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index f3138a1..ea6f868 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -173,7 +173,7 @@ async def bye(self) -> None: self.call_id, self.BYE_ACK_TIMEOUT, ) - self.sip.dialogs.pop((self.remote_tag, self.local_tag), None) + self.sip.del_dialog(self) async def dial( self, @@ -207,8 +207,8 @@ def from_request(cls, request: messages.Request, **kwargs) -> Dialog: """Create a dialog from a request, extracting relevant headers.""" return cls( call_id=request.headers["Call-ID"], - local_tag=request.local_tag, - remote_tag=request.remote_tag or str(uuid.uuid4()), + local_tag=request.remote_tag or str(uuid.uuid4()), + remote_tag=request.local_tag, remote_contact=request.headers.get("Contact"), **kwargs, ) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 7c40fab..890762e 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -101,10 +101,10 @@ async def main(): keepalive_task: asyncio.Task | None = dataclasses.field(init=False, default=None) public_address: NetworkAddress = None - dialogs: dict[tuple[str, str], Dialog] = dataclasses.field( + _dialogs: dict[tuple[str, str], Dialog] = dataclasses.field( init=False, default_factory=dict ) - transactions: dict[str, Transaction] = dataclasses.field( + _transactions: dict[str, Transaction] = dataclasses.field( init=False, default_factory=dict ) disconnected_event: asyncio.Event = dataclasses.field( @@ -117,6 +117,22 @@ async def main(): def __post_init__(self): self.public_address = self.public_address or self.rtp.public_address + def add_dialog(self, dialog: Dialog) -> None: + """Register *dialog* keyed by ``(local_tag, remote_tag)``.""" + self._dialogs[dialog.local_tag, dialog.remote_tag] = dialog + + def del_dialog(self, dialog: Dialog) -> None: + """Remove *dialog* from the registry.""" + self._dialogs.pop((dialog.local_tag, dialog.remote_tag), None) + + def add_transaction(self, tx: Transaction) -> None: + """Register *tx* by its branch parameter.""" + self._transactions[tx.branch] = tx + + def del_transaction(self, tx: Transaction) -> None: + """Remove *tx* from the registry.""" + self._transactions.pop(tx.branch, None) + def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore[override] """Store the TLS/TCP transport and start RTP mux + carrier registration.""" self.transport = transport @@ -124,7 +140,7 @@ def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore try: loop = asyncio.get_running_loop() tx = RegistrationTransaction(sip=self, method=SIPMethod.REGISTER) - self.transactions[tx.branch] = tx + self._transactions[tx.branch] = tx loop.create_task(self.handle_registration(tx)) self.keepalive_task = loop.create_task(self.send_keepalive()) except RuntimeError: @@ -269,16 +285,22 @@ def request_received(self, request: Request) -> None: InviteTransaction.receive(request=request, sip=self) ) case SIPMethod.ACK: + # For non-2xx ACKs the INVITE tx is still present; route by branch. + tx = self._transactions.get(request.branch) + if isinstance(tx, InviteTransaction): + tx.ack_received(request) + return + # For 2xx ACKs the tx is gone; route by established dialog. try: - dialog = self.dialogs[request.remote_tag, request.local_tag] + dialog = self._dialogs[request.remote_tag, request.local_tag] dialog.invite_transaction.ack_received(request) - except KeyError, AttributeError: + except (KeyError, AttributeError): logger.warning("ACK for unknown dialog: %r", request) case SIPMethod.BYE: asyncio.create_task(ByeTransaction.receive(request=request, sip=self)) case SIPMethod.CANCEL: try: - tx = self.transactions[request.branch] + tx = self._transactions[request.branch] except KeyError: self.send( Response.from_request( @@ -308,7 +330,7 @@ def response_received(self, response: Response) -> None: response: The parsed SIP response. """ try: - tx = self.transactions[response.branch] + tx = self._transactions[response.branch] except KeyError: logger.warning( "Received response with unknown branch %r: %r", diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 9047b31..3cc4183 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -128,7 +128,7 @@ def from_request( sip: SessionInitiationProtocol, ): try: - dialog = sip.dialogs[request.remote_tag, request.local_tag] + dialog = sip._dialogs[request.remote_tag, request.local_tag] except KeyError: dialog = sip.dialog_class.from_request(request) return cls( @@ -193,7 +193,7 @@ def response_received(self, response: Response) -> None: Args: response: The parsed SIP response. """ - self.sip.transactions.pop(self.branch) + self.sip.del_transaction(self) match response.status_code: case SIPStatus.OK: logger.info("Registration successful") @@ -260,7 +260,7 @@ def response_received(self, response: Response) -> None: method=self.method, authorization=auth_value, ) - self.sip.transactions[tx.branch] = tx + self.sip.add_transaction(tx) tx.add_done_callback(self.forward_result) case _: raise NotImplementedError( @@ -408,7 +408,7 @@ async def receive( [RFC 3261 §13.3]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.3 """ tx = cls.from_request(request=request, sip=sip) - sip.transactions[request.branch] = tx + sip.add_transaction(tx) tx.dialog.invite_transaction = tx tx.dialog.sip = sip tx.dialog.call_received() @@ -423,7 +423,7 @@ def ack_received(self, request: Request) -> None: Args: request: The SIP ACK request. """ - self.sip.transactions.pop(self.branch, None) + self.sip.del_transaction(self) self.complete() def cancel_received(self, request: Request) -> None: @@ -432,8 +432,8 @@ def cancel_received(self, request: Request) -> None: Args: request: The SIP CANCEL request. """ - self.sip.transactions.pop(self.branch, None) - self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag), None) + self.sip.del_transaction(self) + self.sip.del_dialog(self.dialog) self.send_response( Response.from_request( request, @@ -503,8 +503,6 @@ def call_received(self) -> None: NotImplementedError: When `negotiate_codec` raises (no supported codec in the remote SDP offer). """ - from .dialog import Dialog - peer = ( self.sip.transport.get_extra_info("peername") if self.sip.transport @@ -532,21 +530,17 @@ def call_received(self) -> None: use_srtp = negotiated_media.proto == "RTP/SAVP" srtp_session = SRTPSession.generate() if use_srtp else None - dialog = Dialog.from_request( - self.request, - sip=self.sip, - local_party=f"{self.request.headers['To']};tag={self.request.remote_tag}", - remote_party=str(self.request.headers["From"]), - route_set=list(self.request.headers.getlist("Record-Route")), - ) - self.sip.dialogs[dialog.remote_tag, dialog.local_tag] = dialog + self.dialog.local_party = f"{self.request.headers['To']};tag={self.dialog.local_tag}" + self.dialog.remote_party = str(self.request.headers["From"]) + self.dialog.route_set = list(self.request.headers.getlist("Record-Route")) + self.sip.add_dialog(self.dialog) call_handler = session_class( rtp=self.sip.rtp, caller=caller, media=negotiated_media, srtp=srtp_session, - dialog=dialog, + dialog=self.dialog, **session_kwargs, ) if remote_audio is not None and remote_audio.port != 0: @@ -580,7 +574,7 @@ def call_received(self) -> None: self.send_response( Response.from_request( request=self.request, - dialog=dialog, + dialog=self.dialog, status_code=SIPStatus.OK, phrase=SIPStatus.OK.phrase, headers={ @@ -707,12 +701,12 @@ async def send( }, body=sdp_offer, ) - sip.transactions[tx.branch] = tx + sip.add_transaction(tx) sip.send(tx.request) try: return await tx except asyncio.CancelledError: - sip.transactions.pop(tx.branch, None) + sip.del_transaction(tx) raise def response_received(self, response: Response) -> None: @@ -723,8 +717,10 @@ def response_received(self, response: Response) -> None: case 2: # OK self._start_call(response) self.ack(response) + self.complete() case _: # any other terminal response self.ack(response) + self.complete() def _start_call(self, response: Response) -> None: """Complete call setup after a 200 OK is received. @@ -789,38 +785,30 @@ def _start_call(self, response: Response) -> None: if remote_rtp_address is not None: self.sip.rtp.send(b"\x00", remote_rtp_address) - self.ack(response) - self.sip.transactions.pop(self.branch, None) - self.complete() - def ack(self, response: Response) -> None: - """ - Send an ACK after receiving a terminal response. + """Send an ACK after receiving a terminal response. - Establish a dialog if the response is 200 OK. + For 2xx responses, establishes the dialog and registers it with the + protocol. """ - our_tag = response.local_tag - callee_tag = response.remote_tag - self.dialog.remote_tag = our_tag - self.dialog.local_tag = callee_tag - if response.status_code == SIPStatus.OK: - self.sip.dialogs[(our_tag, callee_tag)] = self.dialog + if response.status_code // 100 == 2: + self.dialog.remote_tag = response.remote_tag + self.dialog.local_party = str(response.headers["From"]) + self.dialog.remote_party = str(response.headers["To"]) + self.dialog.outbound_cseq = self.cseq + 1 + # RFC 3261 §12.1.2: UAC route set is Record-Route in reverse order. + self.dialog.route_set = list( + reversed(list(response.headers.getlist("Record-Route"))) + ) + self.sip.add_dialog(self.dialog) + self.sip.del_transaction(self) ack_branch = f"{Transaction.branch_prefix}-{uuid.uuid4()}" contact = response.headers.get("Contact") ack_uri = ( contact.split(";")[0].strip("<>") if contact else str(self.request.uri) ) - - # Store BYE-ready dialog state now that dialog tags are finalised. - self.dialog.local_party = str(response.headers["From"]) - self.dialog.remote_party = str(response.headers["To"]) self.dialog.remote_contact = ack_uri - self.dialog.outbound_cseq = self.cseq + 1 - # RFC 3261 §12.1.2: UAC route set is Record-Route in reverse order. - self.dialog.route_set = list( - reversed(list(response.headers.getlist("Record-Route"))) - ) ack_headers: SIPHeaderDict = SIPHeaderDict( { "Via": ( @@ -844,7 +832,6 @@ def ack(self, response: Response) -> None: headers=ack_headers, ) ) - self.sip.transactions.pop(self.branch, None) @dataclasses.dataclass(kw_only=True, slots=True) @@ -901,12 +888,12 @@ async def send( for route in dialog.route_set: headers.add("Route", route) tx.request = Request(method=SIPMethod.BYE, uri=request_uri, headers=headers) - sip.transactions[tx.branch] = tx + sip.add_transaction(tx) sip.send(tx.request) try: return await tx except asyncio.CancelledError: - sip.transactions.pop(tx.branch, None) + sip.del_transaction(tx) raise @classmethod @@ -931,7 +918,7 @@ async def receive( [RFC 3261 §15.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-15.1.2 """ tx = cls.from_request(request=request, sip=sip) - sip.dialogs.pop((tx.dialog.remote_tag, tx.dialog.local_tag), None) + sip.del_dialog(tx.dialog) tx.send_response( Response.from_request( request, @@ -951,7 +938,7 @@ def response_received(self, response: Response) -> None: response: The parsed SIP response to our BYE request. """ if response.status_code >= 200: - self.sip.transactions.pop(self.branch, None) + self.sip.del_transaction(self) self.complete() logger.debug( "BYE acknowledged: %s %s", response.status_code, response.phrase From 016661cd4d687f6a645b5e2062cffd32f8f01a41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:22:50 +0000 Subject: [PATCH 41/45] fix: improve docstring and deduplicate complete() in response_received Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/ebac7b0c-c592-467a-ac54-cd951d1e241d Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- voip/sip/protocol.py | 2 +- voip/sip/transactions.py | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 890762e..48816c3 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -118,7 +118,7 @@ def __post_init__(self): self.public_address = self.public_address or self.rtp.public_address def add_dialog(self, dialog: Dialog) -> None: - """Register *dialog* keyed by ``(local_tag, remote_tag)``.""" + """Register *dialog* keyed by ``(dialog.local_tag, dialog.remote_tag)``.""" self._dialogs[dialog.local_tag, dialog.remote_tag] = dialog def del_dialog(self, dialog: Dialog) -> None: diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 3cc4183..aeef758 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -713,14 +713,11 @@ def response_received(self, response: Response) -> None: """Dispatch responses to an outbound INVITE.""" match response.status_code // 100: case 1: # trying/ringing - pass + return case 2: # OK self._start_call(response) - self.ack(response) - self.complete() - case _: # any other terminal response - self.ack(response) - self.complete() + self.ack(response) + self.complete() def _start_call(self, response: Response) -> None: """Complete call setup after a 200 OK is received. From 63c07052123f2b78612ca2be62d2f451ef202036 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:07:11 +0000 Subject: [PATCH 42/45] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- voip/sip/protocol.py | 2 +- voip/sip/transactions.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 48816c3..2e0879a 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -294,7 +294,7 @@ def request_received(self, request: Request) -> None: try: dialog = self._dialogs[request.remote_tag, request.local_tag] dialog.invite_transaction.ack_received(request) - except (KeyError, AttributeError): + except KeyError, AttributeError: logger.warning("ACK for unknown dialog: %r", request) case SIPMethod.BYE: asyncio.create_task(ByeTransaction.receive(request=request, sip=self)) diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index aeef758..2bc9d9f 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -530,7 +530,9 @@ def call_received(self) -> None: use_srtp = negotiated_media.proto == "RTP/SAVP" srtp_session = SRTPSession.generate() if use_srtp else None - self.dialog.local_party = f"{self.request.headers['To']};tag={self.dialog.local_tag}" + self.dialog.local_party = ( + f"{self.request.headers['To']};tag={self.dialog.local_tag}" + ) self.dialog.remote_party = str(self.request.headers["From"]) self.dialog.route_set = list(self.request.headers.getlist("Record-Route")) self.sip.add_dialog(self.dialog) From b947eedcc34f14e708c43b9be2c3cee8a55f6974 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Tue, 7 Apr 2026 12:12:38 +0200 Subject: [PATCH 43/45] Rename things --- voip/sip/dialog.py | 2 +- voip/sip/protocol.py | 30 +++++++++++++++++++++--------- voip/sip/transactions.py | 30 +++++++++++++++--------------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index ea6f868..0371140 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -173,7 +173,7 @@ async def bye(self) -> None: self.call_id, self.BYE_ACK_TIMEOUT, ) - self.sip.del_dialog(self) + self.sip.drop_dialog(self) async def dial( self, diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 2e0879a..1f2dbb4 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -117,21 +117,33 @@ async def main(): def __post_init__(self): self.public_address = self.public_address or self.rtp.public_address - def add_dialog(self, dialog: Dialog) -> None: + def register_dialog(self, dialog: Dialog) -> None: """Register *dialog* keyed by ``(dialog.local_tag, dialog.remote_tag)``.""" - self._dialogs[dialog.local_tag, dialog.remote_tag] = dialog + if dialog.remote_tag is None: + logger.warning("Dialog without remote tag cannot be registered: %r", dialog) + else: + self._dialogs[dialog.local_tag, dialog.remote_tag] = dialog - def del_dialog(self, dialog: Dialog) -> None: + def drop_dialog(self, dialog: Dialog) -> None: """Remove *dialog* from the registry.""" - self._dialogs.pop((dialog.local_tag, dialog.remote_tag), None) + if dialog.remote_tag is None: + logger.warning("Dialog without remote tag cannot be removed: %r", dialog) + else: + try: + del self._dialogs[dialog.local_tag, dialog.remote_tag] + except KeyError: + logger.warning("Dialog not found for removal: %r", dialog) - def add_transaction(self, tx: Transaction) -> None: + def register_transaction(self, tx: Transaction) -> None: """Register *tx* by its branch parameter.""" self._transactions[tx.branch] = tx - def del_transaction(self, tx: Transaction) -> None: + def drop_transaction(self, tx: Transaction) -> None: """Remove *tx* from the registry.""" - self._transactions.pop(tx.branch, None) + try: + del self._transactions[tx.branch] + except KeyError: + logger.warning("Transaction not found for removal: %r", tx) def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore[override] """Store the TLS/TCP transport and start RTP mux + carrier registration.""" @@ -140,7 +152,7 @@ def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore try: loop = asyncio.get_running_loop() tx = RegistrationTransaction(sip=self, method=SIPMethod.REGISTER) - self._transactions[tx.branch] = tx + self.register_transaction(tx) loop.create_task(self.handle_registration(tx)) self.keepalive_task = loop.create_task(self.send_keepalive()) except RuntimeError: @@ -286,7 +298,7 @@ def request_received(self, request: Request) -> None: ) case SIPMethod.ACK: # For non-2xx ACKs the INVITE tx is still present; route by branch. - tx = self._transactions.get(request.branch) + tx = self._transactions[request.branch] if isinstance(tx, InviteTransaction): tx.ack_received(request) return diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 2bc9d9f..a941c44 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -193,7 +193,7 @@ def response_received(self, response: Response) -> None: Args: response: The parsed SIP response. """ - self.sip.del_transaction(self) + self.sip.drop_transaction(self) match response.status_code: case SIPStatus.OK: logger.info("Registration successful") @@ -260,7 +260,7 @@ def response_received(self, response: Response) -> None: method=self.method, authorization=auth_value, ) - self.sip.add_transaction(tx) + self.sip.register_transaction(tx) tx.add_done_callback(self.forward_result) case _: raise NotImplementedError( @@ -408,7 +408,7 @@ async def receive( [RFC 3261 §13.3]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.3 """ tx = cls.from_request(request=request, sip=sip) - sip.add_transaction(tx) + sip.register_transaction(tx) tx.dialog.invite_transaction = tx tx.dialog.sip = sip tx.dialog.call_received() @@ -423,7 +423,7 @@ def ack_received(self, request: Request) -> None: Args: request: The SIP ACK request. """ - self.sip.del_transaction(self) + self.sip.drop_transaction(self) self.complete() def cancel_received(self, request: Request) -> None: @@ -432,8 +432,8 @@ def cancel_received(self, request: Request) -> None: Args: request: The SIP CANCEL request. """ - self.sip.del_transaction(self) - self.sip.del_dialog(self.dialog) + self.sip.drop_transaction(self) + self.sip.drop_dialog(self.dialog) self.send_response( Response.from_request( request, @@ -535,7 +535,7 @@ def call_received(self) -> None: ) self.dialog.remote_party = str(self.request.headers["From"]) self.dialog.route_set = list(self.request.headers.getlist("Record-Route")) - self.sip.add_dialog(self.dialog) + self.sip.register_dialog(self.dialog) call_handler = session_class( rtp=self.sip.rtp, @@ -703,12 +703,12 @@ async def send( }, body=sdp_offer, ) - sip.add_transaction(tx) + sip.register_transaction(tx) sip.send(tx.request) try: return await tx except asyncio.CancelledError: - sip.del_transaction(tx) + sip.drop_transaction(tx) raise def response_received(self, response: Response) -> None: @@ -799,8 +799,8 @@ def ack(self, response: Response) -> None: self.dialog.route_set = list( reversed(list(response.headers.getlist("Record-Route"))) ) - self.sip.add_dialog(self.dialog) - self.sip.del_transaction(self) + self.sip.register_dialog(self.dialog) + self.sip.drop_transaction(self) ack_branch = f"{Transaction.branch_prefix}-{uuid.uuid4()}" contact = response.headers.get("Contact") @@ -887,12 +887,12 @@ async def send( for route in dialog.route_set: headers.add("Route", route) tx.request = Request(method=SIPMethod.BYE, uri=request_uri, headers=headers) - sip.add_transaction(tx) + sip.register_transaction(tx) sip.send(tx.request) try: return await tx except asyncio.CancelledError: - sip.del_transaction(tx) + sip.drop_transaction(tx) raise @classmethod @@ -917,7 +917,7 @@ async def receive( [RFC 3261 §15.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-15.1.2 """ tx = cls.from_request(request=request, sip=sip) - sip.del_dialog(tx.dialog) + sip.drop_dialog(tx.dialog) tx.send_response( Response.from_request( request, @@ -937,7 +937,7 @@ def response_received(self, response: Response) -> None: response: The parsed SIP response to our BYE request. """ if response.status_code >= 200: - self.sip.del_transaction(self) + self.sip.drop_transaction(self) self.complete() logger.debug( "BYE acknowledged: %s %s", response.status_code, response.phrase From acb0b12e2c04a7c0f8749648bbd566b4efaaa960 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Tue, 7 Apr 2026 14:32:57 +0200 Subject: [PATCH 44/45] Slim down implementation --- voip/sip/dialog.py | 2 +- voip/sip/messages.py | 2 +- voip/sip/protocol.py | 20 +++++++++++--------- voip/sip/types.py | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index 0371140..b35666f 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -149,7 +149,7 @@ def reject(self, status_code: types.SIPStatus = types.SIPStatus.BUSY_HERE) -> No Common status codes include: - [BUSY_HERE][voip.sip.types.SIPStatus.BUSY_HERE]: The remote party will hear a busy signal. - - [DECLINE][voip.sip.types.SIPStatus.DECLINE]: The remote party will hera a decline signal. + - [DECLINE][voip.sip.types.SIPStatus.DECLINE]: The remote party will hear a decline signal. - [DOES_NOT_EXIST_ANYWHERE][voip.sip.types.SIPStatus.DOES_NOT_EXIST_ANYWHERE]: The remote party will hear a "The person you are trying to reach…" message. Args: diff --git a/voip/sip/messages.py b/voip/sip/messages.py index 44653b4..3fd5cc9 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -111,7 +111,7 @@ def __bytes__(self) -> bytes: ) @property - def branch(self) -> str | None: + def branch(self) -> str: """Branch parameter from the top Via header (RFC 3261 §20.42).""" _, uri = self.headers["Via"].split() return SipURI.parse(f"sip:{uri}").parameters["branch"] diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 1f2dbb4..c574d9f 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -298,16 +298,18 @@ def request_received(self, request: Request) -> None: ) case SIPMethod.ACK: # For non-2xx ACKs the INVITE tx is still present; route by branch. - tx = self._transactions[request.branch] - if isinstance(tx, InviteTransaction): - tx.ack_received(request) - return - # For 2xx ACKs the tx is gone; route by established dialog. try: - dialog = self._dialogs[request.remote_tag, request.local_tag] - dialog.invite_transaction.ack_received(request) - except KeyError, AttributeError: - logger.warning("ACK for unknown dialog: %r", request) + tx = self._transactions[request.branch] + except KeyError: + self.send( + Response.from_request( + request, + status_code=SIPStatus.GONE, + phrase=SIPStatus.GONE.phrase, + ) + ) + else: + tx.ack_received(request) case SIPMethod.BYE: asyncio.create_task(ByeTransaction.receive(request=request, sip=self)) case SIPMethod.CANCEL: diff --git a/voip/sip/types.py b/voip/sip/types.py index 0e8a025..cd5a886 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -78,7 +78,7 @@ def __new__( user: str | None = None, password: str | None = None, port: int | None = None, - parameters: dict[str, str | None] | None = None, + parameters: dict[str, str] = None, headers: dict[str, str] | None = None, ) -> SipURI: try: From 92ea1bb0f6141f2d263fad6f9ceab2f06d0d3e5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:44:11 +0000 Subject: [PATCH 45/45] Fix cookbook.md examples to match current API Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/cd23d7e2-9dfe-4452-a557-1803c71d6592 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- docs/cookbook.md | 17 ++++++++++------- voip/sip/dialog.py | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/cookbook.md b/docs/cookbook.md index 2bc86d1..0aed0cc 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -151,7 +151,7 @@ class RecordCall(AudioCall): ## Sending Audio to the Caller -Use [\_send_rtp_audio][voip.audio.AudioCall.\_send_rtp_audio] inside any +Use [send_audio][voip.audio.AudioCall.send_audio] inside any [AudioCall][voip.audio.AudioCall] subclass to stream float32 PCM back to the caller using the negotiated codec: @@ -159,14 +159,14 @@ the caller using the negotiated codec: import asyncio import numpy as np import soundfile as sf -from voip.audio import AudioCall, SAMPLE_RATE +from voip.audio import AudioCall class GreetingCall(AudioCall): async def play_greeting(self) -> None: audio, file_rate = sf.read("greeting.wav", dtype="float32", always_2d=False) - resampled = self._resample(audio, file_rate, SAMPLE_RATE) - await self._send_rtp_audio(resampled) + resampled = self.resample(audio, file_rate, self.sampling_rate_hz) + await self.send_audio(resampled) ``` ## Low-Level RTP Packet Handling @@ -234,12 +234,12 @@ import ssl import numpy as np -from voip.audio import AudioCall +from voip.audio import VoiceActivityCall from voip.sip.dialog import Dialog from voip.sip.protocol import SIP -class OneUtteranceCall(AudioCall): +class OneUtteranceCall(VoiceActivityCall): """Hang up as soon as the first voice utterance is received.""" async def voice_received(self, audio: np.ndarray) -> None: @@ -295,6 +295,7 @@ import asyncio import ssl from voip.audio import AudioCall +from voip.sip import SipURI from voip.sip.dialog import Dialog from voip.sip.protocol import SIP @@ -316,7 +317,9 @@ class MySession(SIP): def on_registered(self) -> None: dialog = OutboundDialog(sip=self) asyncio.create_task( - dialog.dial("sip:+15551234567@carrier.com", session_class=MyCall) + dialog.dial( + SipURI.parse("sip:+15551234567@carrier.com"), session_class=MyCall + ) ) diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index b35666f..d8c90c3 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -26,7 +26,7 @@ class Dialog: class MyDialog(Dialog): def call_received(self) -> None: self.ringing() - self.accept(session_class=MyCall) + self.answer(session_class=MyCall) class MySession(SessionInitiationProtocol): dialog_class = MyDialog