diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 06f5104..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 @@ -36,6 +38,7 @@ repos: - mdformat-footnote - mdformat-gfm - mdformat-gfm-alerts + - 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..5fec614 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,23 +9,23 @@ 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 ``` 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 +```console +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 +```console +uvx prek install ``` ## Writing documentation @@ -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 ``` diff --git a/README.md b/README.md index 546fd87..cc874e1 100644 --- a/README.md +++ b/README.md @@ -20,25 +20,41 @@ Async VoIP Python library for the AI age. ## Usage +To get started, you will need a SIP account. One is usually included with ISP. +Check your ISP's documentation or router for details. + +You will need a SIP AOR (URI), which looks like this: + +```INI +sip:USER:PASSWORD@SIP_SERVER;transport=TCP +``` + +> [!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. + ### CLI -Answer calls and transcribe them live from the terminal: +A simple echo call can be started with: ```console -uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe +uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo ``` -A simple echo server can be started with: +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 ```console -uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo -```` +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]): ```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 @@ -48,7 +64,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 @@ -56,7 +72,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 @@ -72,7 +88,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"), ) @@ -87,7 +103,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/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 ef508e3..0aed0cc 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -1,9 +1,15 @@ # Cookbook +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. + +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 @@ -11,26 +17,31 @@ import asyncio import ssl from voip.ai import TranscribeCall +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 MySession(SIP): - def call_received(self, request) -> None: - asyncio.create_task(self.answer(request=request, call_class=MyCall)) +class AutoAcceptDialog(Dialog): + """Accept every incoming call and transcribe it using MyCall.""" + + def call_received(self) -> None: + self.ringing() + self.answer(session_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, @@ -44,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 @@ -62,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: @@ -74,6 +85,7 @@ import ssl from pocket_tts import TTSModel from voip.ai import AgentCall +from voip.sip.dialog import Dialog from voip.sip.protocol import SIP shared_tts = TTSModel.load_model() @@ -86,9 +98,14 @@ class MyCall(AgentCall): voice = "azelma" +class MyDialog(Dialog): + def call_received(self) -> None: + self.ringing() + self.answer(session_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(): @@ -109,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 @@ -134,28 +151,28 @@ 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_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: ```python 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 -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 @@ -198,3 +215,128 @@ 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) by delegating to +[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.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 +import ssl + +import numpy as np + +from voip.audio import VoiceActivityCall +from voip.sip.dialog import Dialog +from voip.sip.protocol import SIP + + +class OneUtteranceCall(VoiceActivityCall): + """Hang up as soon as the first voice utterance is received.""" + + async def voice_received(self, audio: np.ndarray) -> None: + await self.hang_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.answer(session_class=OneUtteranceCall) + + +class MySession(SIP): + dialog_class = MyDialog + + +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. Access `self.dialog.sip.close()` when you also want to +tear down the transport. + +## Making Outbound Calls + +Create a [Dialog][voip.sip.Dialog] subclass, set it as +`dialog_class` on your SIP session, and call +[dial][voip.sip.Dialog.dial] from +[on_registered]\[voip.sip.protocol.SessionInitiationProtocol.on_registered\]: + +```python +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 + + +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( + SipURI.parse("sip:+15551234567@carrier.com"), session_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/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 9cc1ef3..92f3dc9 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -1,6 +1,11 @@ -# Multimedia Dessions / Call Leg Handlers +# Multimedia Sessions -[Session][voip.rtp.Session] is the base class for all call leg handlers. +[Session][voip.rtp.Session] and its subclasses handle the media exchange between call parties. +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. + +::: voip.rtp.Session ## Audio Handling @@ -15,3 +20,5 @@ ::: voip.ai.TranscribeCall ::: voip.ai.AgentCall + +::: voip.ai.SayCall diff --git a/docs/sip.md b/docs/sip.md index d2565fa..fdb3b27 100644 --- a/docs/sip.md +++ b/docs/sip.md @@ -1,9 +1,28 @@ # Session Initiation Protocol (SIP) -::: voip.sip +::: voip.sip.Dialog + options: + heading_level: 2 + members: + - call_received + - hangup_received + - ringing + - answer + - reject + - dial + - bye -## Transactions +::: voip.sip.SessionInitiationProtocol + options: + heading_level: 2 + members: false -::: voip.sip.transactions.InviteTransaction +## Types -::: voip.sip.transactions.RegistrationTransaction +::: voip.sip.SipURI + +::: voip.sip.CallerID + +::: voip.sip.SIPStatus + +::: voip.sdp.types diff --git a/mkdocs.yml b/mkdocs.yml index 13b2a5f..082f4bd 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 @@ -33,6 +32,7 @@ plugins: 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/tests/sip/conftest.py b/tests/sip/conftest.py index 0be1d30..8423d69 100644 --- a/tests/sip/conftest.py +++ b/tests/sip/conftest.py @@ -6,9 +6,9 @@ import pytest from voip.rtp import RealtimeTransportProtocol, Session from voip.sdp.types import MediaDescription, RTPPayloadFormat +from voip.sip.dialog import Dialog from voip.sip.protocol import SessionInitiationProtocol -from voip.sip.transactions import InviteTransaction -from voip.sip.types import SipUri +from voip.sip.types import SipURI from voip.types import NetworkAddress @@ -77,9 +77,9 @@ 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, - 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 f705cb5..32e795d 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -2,8 +2,55 @@ 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.dialog import Dialog +from voip.sip.types import SipURI + + +class TestSIPHeaderDict: + 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" + ) + + 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: @@ -14,8 +61,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 +79,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 +97,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 +111,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 +140,22 @@ 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_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 = Request( + request = messages.Request( method="REGISTER", uri="sip:registrar.biloxi.com", headers={"From": "sip:bob@biloxi.com"}, @@ -139,7 +169,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 +180,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 +191,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 +202,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 +223,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 +234,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"}, @@ -214,11 +244,11 @@ 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", ) - 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 +262,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 +276,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 +284,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 +301,12 @@ def test_from_request__with_dialog_remote_tag(self): b"CSeq: 1 INVITE\r\n" b"\r\n" ) - request = Message.parse(data) + 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 = 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,57 +322,6 @@ 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( - 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 = 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 = 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 = 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 = Message.parse(data) - dialog = 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 deleted file mode 100644 index a87757f..0000000 --- a/tests/sip/test_protocol.py +++ /dev/null @@ -1,859 +0,0 @@ -"""Tests for the SIP asyncio protocol handler.""" - -import asyncio -import datetime -import ipaddress - -from voip.sip.messages import 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, - transaction_class=InviteTransaction, - ) - 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, - transaction_class=InviteTransaction, - ) - 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, - transaction_class=InviteTransaction, - ) - 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, - transaction_class=InviteTransaction, - ) - 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, - transaction_class=InviteTransaction, - ) - 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, - transaction_class=InviteTransaction, - ) - 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_when_transaction_class_has_handler( - self, sip - ): - """Include INVITE when transaction_class 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 diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py deleted file mode 100644 index e14e377..0000000 --- a/tests/sip/test_transactions.py +++ /dev/null @@ -1,672 +0,0 @@ -"""Tests for the SIP transaction layer.""" - -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 ( - 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.protocol import SessionInitiationProtocol - - transport = fake_transport or FakeTransport() - mux = rtp or RealtimeTransportProtocol() - session = SessionInitiationProtocol( - aor=SipUri.parse("sips:alice:secret@example.com"), - rtp=mux, - transaction_class=InviteTransaction, - ) - # 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 - - 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", - ) - - -# --------------------------------------------------------------------------- -# InviteTransaction -# --------------------------------------------------------------------------- - - -class TestInviteTransaction: - def test_invite_received__is_noop(self): - """invite_received base implementation does nothing.""" - sip = create_sip_session() - request = Message.parse(INVITE_BYTES) - tx = InviteTransaction.from_request(request=request, sip=sip) - assert tx.invite_received(request) is None - - 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 - - def test_bye_received__removes_dialog_and_sends_200(self): - """bye_received removes 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.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_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__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__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) - - 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) - - -# --------------------------------------------------------------------------- -# RegistrationError -# --------------------------------------------------------------------------- - - -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/sip/test_types.py b/tests/sip/test_types.py index 6d9138c..f996c20 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -1,70 +1,70 @@ import ipaddress import pytest -from voip.sip import SipUri +from voip.sip import SipURI from voip.sip.messages import Response from voip.sip.types import CallerID -class TestSipUri: +class TestSipURI: @pytest.mark.parametrize( "uri_str, expected_uri_obj", [ # 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,39 +268,47 @@ 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 + 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) + def _ok() -> Response: return Response(status_code=200, phrase="OK") @@ -370,6 +378,29 @@ 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__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.""" + 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.""" + assert CallerID("tel:+15551234567").host is None + class TestMaskCaller: def test_mask_caller__with_display_name(self): diff --git a/tests/test_ai.py b/tests/test_ai.py deleted file mode 100644 index f6b1022..0000000 --- a/tests/test_ai.py +++ /dev/null @@ -1,548 +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, 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, -) -> 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, - ) - - -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) - - 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 - - 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 - - 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 - - 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): - """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 call._messages[0]["role"] == "system" - assert "phone" in call._messages[0]["content"].lower() - - 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 - - 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() - - 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 - - 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[1] == {"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 diff --git a/tests/test_audio.py b/tests/test_audio.py deleted file mode 100644 index 4a5618a..0000000 --- a/tests/test_audio.py +++ /dev/null @@ -1,660 +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() - - -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 acc2acb..839e97d 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 @@ -20,10 +21,10 @@ 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(), + "dialog": Dialog(), "media": make_media(), "caller": CallerID(""), } @@ -162,7 +163,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = RecordCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") ) remote_addr = ("127.0.0.1", 5004) mux.register_call(remote_addr, handler) @@ -189,7 +190,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = RecordCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + 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. @@ -207,7 +208,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, 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]) @@ -293,10 +294,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, dialog=Dialog(), media=make_media(), caller=CallerID("") ) specific_handler = SpecificCall( - rtp=mux, sip=MagicMock(), 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) @@ -318,7 +319,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = WildcardCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") ) mux.register_call(None, handler) @@ -338,7 +339,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = RecordCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") ) remote_addr = ("5.6.7.8", 5004) mux.register_call(remote_addr, handler) @@ -354,7 +355,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, 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) @@ -367,7 +368,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, dialog=Dialog(), media=make_media(), caller=CallerID("") ) addr = ("1.2.3.4", 5004) mux.register_call(addr, handler) @@ -386,7 +387,7 @@ def packet_received(self, packet: RTPPacket, addr): mux = RealtimeTransportProtocol() handler = CapturingCall( - rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") + rtp=mux, dialog=Dialog(), media=make_media(), caller=CallerID("") ) mux.register_call(None, handler) packet = make_rtp_packet() @@ -430,7 +431,7 @@ def packet_received(self, packet: RTPPacket, addr) -> None: session = SRTPSession.generate() handler = SRTPCapture( rtp=mux, - sip=MagicMock(), + dialog=Dialog(), media=make_media(), srtp=session, caller=CallerID(""), @@ -462,7 +463,7 @@ def packet_received(self, packet: RTPPacket, addr) -> None: session = SRTPSession.generate() handler = SRTPCapture( rtp=mux, - sip=MagicMock(), + dialog=Dialog(), media=make_media(), srtp=session, caller=CallerID(""), @@ -495,15 +496,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, dialog=Dialog(), 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.""" @@ -540,8 +539,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__raises_not_implemented(self): - """hang_up raises NotImplementedError in the base class.""" - with pytest.raises(NotImplementedError): - await make_call().hang_up() diff --git a/voip/__main__.py b/voip/__main__.py index 6a8f703..e68207a 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,11 @@ import ssl import time -from voip.rtp import RealtimeTransportProtocol -from voip.sip import messages +from voip.ai import SayCall +from voip.rtp import RealtimeTransportProtocol, Session +from voip.sip import dialog, messages from voip.sip.protocol import SessionInitiationProtocol -from voip.sip.transactions import InviteTransaction -from voip.sip.types import SipUri +from voip.sip.types import SipURI, parse_uri from voip.types import NetworkAddress try: @@ -46,8 +47,8 @@ def response_received(self, response: messages.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. @@ -115,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 @@ -174,36 +175,118 @@ 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: + 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, + session_class: type[Session], + session_kwargs: dict, +) -> collections.abc.Callable[[], ConsoleMessageProtocol]: + + class OutboundDialog(dialog.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: SipURI + + def on_registered(self) -> None: + dialog = OutboundDialog(sip=self) + asyncio.create_task( + dialog.dial( + self.dial_target, session_class=session_class, **session_kwargs + ) + ) + + def factory() -> ConsoleMessageProtocol: + return OutboundProtocol( + verbose=verbose, + dialog_class=OutboundDialog, + aor=aor, + rtp=rtp_protocol, + dial_target=target_uri, + ) + + return factory + + @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): +def echo(ctx, dial: str | None): """Echo the caller's speech back after they finish speaking.""" from .audio import EchoCall # noqa: PLC0415 obj = ctx.obj aor = obj["aor"] - class EchoInviteTransaction(InviteTransaction): - def invite_received(self, request: messages.Request) -> None: + class EchoDialog(dialog.Dialog): + def call_received(self) -> None: self.ringing() - self.answer(call_class=EchoCall) + self.answer(session_class=EchoCall) async def run(): _, rtp_protocol = await _connect_rtp( 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 dial is None: + await _connect_sip( + lambda: ConsoleMessageProtocol( + verbose=obj.get("verbose", 0), + dialog_class=EchoDialog, + 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=parse_uri(dial, aor), + session_class=EchoCall, + session_kwargs={}, + ), + aor.maddr, + aor.transport == "TLS", + obj["no_verify_tls"], + ) try: asyncio.run(run()) @@ -219,8 +302,14 @@ 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): +def transcribe(ctx, stt_model, dial: str | None): """Transcribe incoming call audio.""" from faster_whisper import WhisperModel @@ -236,11 +325,11 @@ 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(dialog.Dialog): + def call_received(self) -> None: self.ringing() self.answer( - call_class=TranscribingCall, + session_class=TranscribingCall, stt_model=WhisperModel(stt_model), ) @@ -249,17 +338,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 dial is None: + await _connect_sip( + lambda: ConsoleMessageProtocol( + verbose=obj.get("verbose", 0), + dialog_class=TranscribeDialog, + 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=parse_uri(dial, aor), + session_class=TranscribingCall, + session_kwargs={"stt_model": WhisperModel(stt_model)}, + ), + aor.maddr, + aor.transport == "TLS", + obj["no_verify_tls"], + ) try: asyncio.run(run()) @@ -299,8 +403,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 @@ -334,28 +455,91 @@ 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(dialog.Dialog): + def call_received(self) -> None: self.ringing() self.answer( - call_class=AgentCallWithOutput, + session_class=AgentCallWithOutput, stt_model=WhisperModel(stt_model), 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 dial is None: + await _connect_sip( + lambda: ConsoleMessageProtocol( + verbose=obj.get("verbose", 0), + dialog_class=AgentDialog, + 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=parse_uri(dial, aor), + session_class=AgentCallWithOutput, + session_kwargs={ + "stt_model": WhisperModel(stt_model), + "llm_model": llm_model, + "voice": voice, + "system_prompt": system_prompt, + "salutation": 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"] 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=parse_uri(target, aor), + 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 66c2571..b0857de 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]``. @@ -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__) @@ -35,27 +35,27 @@ 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 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") @@ -96,10 +96,69 @@ def transcription_received(self, text: str) -> None: """ -@dataclasses.dataclass(kw_only=True, slots=True) -class AgentCall(TranscribeCall): +@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. """ - Respond to caller voice inputs with voice responses. + + 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 SayCall(TTSMixin, AudioCall): + """Dial a number, say a message using TTS, and hang up.""" + + text: str + + def __post_init__(self) -> None: + super().__post_init__() + asyncio.create_task(self.send_speech(self.text)) + + def on_audio_sent(self) -> None: + asyncio.create_task(self.hang_up()) + + async def hang_up(self) -> None: + await super().hang_up() + if self.dialog is not None and self.dialog.sip is not None: + self.dialog.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 +168,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 +178,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 +204,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 +226,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 +242,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..4fb4bd3 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 @@ -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() @@ -304,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 @@ -318,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 @@ -416,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/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 5c8e770..fdb99ad 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -13,13 +13,13 @@ 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 if TYPE_CHECKING: - from voip.sip.protocol import SessionInitiationProtocol + from voip.sip.dialog import Dialog from voip.sip.types import CallerID __all__ = ["RTP", "Session", "RTPPacket", "RTPPayloadType", "RealtimeTransportProtocol"] @@ -94,22 +94,23 @@ 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. + 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. """ rtp: RealtimeTransportProtocol - sip: SessionInitiationProtocol + dialog: Dialog media: MediaDescription caller: CallerID srtp: SRTPSession | None = None @@ -137,13 +138,30 @@ 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. + Deregisters this call from the RTP multiplexer, then delegates the + 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. + + 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 """ - raise NotImplementedError("hang_up is not yet implemented") + if self.dialog is None: + return + # 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), + _not_found, + ) + if remote_addr is not _not_found: + self.rtp.unregister_call(remote_addr) + await self.dialog.bye() @classmethod def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: @@ -168,6 +186,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/__init__.py b/voip/sip/__init__.py index 10d5f49..e8fbbb2 100644 --- a/voip/sip/__init__.py +++ b/voip/sip/__init__.py @@ -4,20 +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 InviteTransaction, RegistrationTransaction -from .types import CallerID, SIPMethod, SIPStatus, SipUri +from .types import CallerID, SIPMethod, SIPStatus, SipURI __all__ = [ + "CallerID", + "SipURI", + "SIPStatus", + "SIPMethod", "Message", "Request", "Response", + "Dialog", "SessionInitiationProtocol", - "InviteTransaction", - "RegistrationTransaction", - "CallerID", - "SipUri", - "SIPStatus", - "SIPMethod", ] diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py new file mode 100644 index 0000000..d8c90c3 --- /dev/null +++ b/voip/sip/dialog.py @@ -0,0 +1,214 @@ +import asyncio +import dataclasses +import datetime +import logging +import socket +import typing +import uuid + +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") + + +@dataclasses.dataclass(kw_only=True, slots=True) +class Dialog: + """Peer-to-peer SIP relationship between two user agents [RFC 3261 §12]. + + Subclass `Dialog` to implement call handling. Set the subclass as + `dialog_class` on the SIP session for inbound calls: + + ```python + class MyDialog(Dialog): + def call_received(self) -> None: + self.ringing() + self.answer(session_class=MyCall) + + class MySession(SessionInitiationProtocol): + dialog_class = MyDialog + ``` + + For outbound calls: + + ```python + dialog = Dialog(sip=my_sip_session) + await dialog.dial("sip:bob@biloxi.com", session_class=MyCall) + ``` + + + [RFC 3261 §12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 + + Args: + sip: The parent protocol the session belongs to. + """ + + # 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 + + uac: SipURI = 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_transaction: 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: + """ + Called when an INVITE is received from the remote party. + + 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 + 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 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. + """ + if self.invite_transaction is not None: + self.invite_transaction.ringing() + + def answer( + self, *, session_class: type[Session], **session_kwargs: typing.Any + ) -> None: + """ + 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 `session_class`. + """ + if self.invite_transaction is not None: + 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 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 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: + status_code: SIP response status code (default: 486 Busy Here). + """ + if self.invite_transaction is not None: + self.invite_transaction.reject(status_code) + + async def bye(self) -> None: + """End the call and terminate the dialog and multimedia session.""" + from voip.sip.transactions import ByeTransaction # noqa: PLC0415 + + try: + await asyncio.wait_for( + ByeTransaction.send(sip=self.sip, dialog=self), + timeout=self.BYE_ACK_TIMEOUT.total_seconds(), + ) + except TimeoutError: + logger.warning( + "BYE for dialog %s was not acknowledged within %r", + self.call_id, + self.BYE_ACK_TIMEOUT, + ) + self.sip.drop_dialog(self) + + async def dial( + self, + target: SipURI, + *, + session_class: type[Session], + **session_kwargs: typing.Any, + ) -> None: + """ + Initiate an outbound call to *target*. + + Args: + 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`. + + [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 + """ + from voip.sip.transactions import InviteTransaction # noqa: PLC0415 + + await InviteTransaction.send( + sip=self.sip, + target=target, + dialog=self, + session_class=session_class, + **session_kwargs, + ) + + @classmethod + 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.remote_tag or str(uuid.uuid4()), + remote_tag=request.local_tag, + remote_contact=request.headers.get("Contact"), + **kwargs, + ) diff --git a/voip/sip/messages.py b/voip/sip/messages.py index cfe549d..3fd5cc9 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -2,19 +2,53 @@ import abc import dataclasses -import datetime -import socket -import uuid +import logging +import platform +import typing +from urllib3 import HTTPHeaderDict + +import voip 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 + +__all__ = ["Request", "Response", "Message"] -__all__ = ["Request", "Response", "Message", "Dialog"] +logger = logging.getLogger("voip.sip") #: Headers whose values are parsed as `CallerID` objects. -_CALLER_HEADERS = frozenset({"From", "To"}) +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.""" + + 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: {line!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 +59,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,30 +104,27 @@ 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: + 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"] + return SipURI.parse(f"sip:{uri}").parameters["branch"] @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: @@ -117,7 +144,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}" @@ -160,70 +187,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] - - [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. - - """ - - 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) - - 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}@{socket.gethostname()};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, - } - - @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 b2f5188..c574d9f 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 @@ -15,8 +14,14 @@ from ..types import NetworkAddress from . import types -from .messages import Dialog, Message, Request, Response -from .transactions import InviteTransaction, RegistrationTransaction, Transaction +from .dialog import Dialog +from .messages import USER_AGENT, Message, Request, Response +from .transactions import ( + ByeTransaction, + InviteTransaction, + RegistrationTransaction, + Transaction, +) from .types import ( SIPMethod, SIPStatus, @@ -43,60 +48,63 @@ 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. + Handles SIP message parsing, carrier registration, and transaction management. - ```python - class MyTransaction(Transaction): - def call_received(self, request: Request) -> None: - asyncio.create_task(self.answer(call_class=MyCall)) + Example: + You can use the handler like any [asyncio.Protocol][asyncio.Protocol] in Python. - class MySession(SessionInitiationProtocol): - transaction_class = MyTransaction - ``` + ```python + import asyncio - To register with a carrier on startup, pass the registration parameters: + from voip.sip import SessionInitiationProtocol - ```python - session = SessionInitiationProtocol( - aor="sips:alice@example.com", - username="alice", - password="secret", - ) - ``` + 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() - [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). + 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 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. - registration_class: Transaction subclass to handle registration transactions. + aor: SIP Address of Record (AOR) to register with the carrier. + rtp: Shared RTP mux for call media. + dialog_class: [Dialog][voip.sip.Dialog] subclass used to + create dialogs for incoming calls. Defaults to the base + [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. """ - VIA_BRANCH_PREFIX: typing.ClassVar[str] = "z9hG4bK" - - aor: types.SipUri + aor: types.SipURI rtp: RealtimeTransportProtocol - transaction_class: type[InviteTransaction] - registration_class: type[RegistrationTransaction] = RegistrationTransaction + dialog_class: type[Dialog] = dataclasses.field(default=Dialog) 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) - dialogs: dict[tuple[str, str], Dialog] = dataclasses.field( + public_address: NetworkAddress = None + _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( @@ -106,18 +114,46 @@ class MySession(SessionInitiationProtocol): 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 register_dialog(self, dialog: Dialog) -> None: + """Register *dialog* keyed by ``(dialog.local_tag, dialog.remote_tag)``.""" + 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 drop_dialog(self, dialog: Dialog) -> None: + """Remove *dialog* from the registry.""" + 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 register_transaction(self, tx: Transaction) -> None: + """Register *tx* by its branch parameter.""" + self._transactions[tx.branch] = tx + + def drop_transaction(self, tx: Transaction) -> None: + """Remove *tx* from the registry.""" + 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.""" 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() 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: pass # no running loop in synchronous test setups @@ -127,9 +163,13 @@ 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: + await tx + self.on_registered() + def data_received(self, data: bytes) -> None: self.recv_buffer.extend(data) for frame in self._extract_frames(): @@ -178,7 +218,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)): @@ -200,6 +240,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)) @@ -210,23 +251,15 @@ def close(self) -> None: @property 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). - - Returns: - Frozenset of [`SIPMethod`][voip.sip.types.SIPMethod] values. - """ + """SIP methods supported by this UA.""" return frozenset( - ( - *( - m - for m in SIPMethod - if hasattr(self.transaction_class, f"{m.lower()}_received") - ), + { + SIPMethod.INVITE, + SIPMethod.ACK, + SIPMethod.BYE, + SIPMethod.CANCEL, SIPMethod.OPTIONS, - ) + } ) @property @@ -257,11 +290,31 @@ 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: + # For non-2xx ACKs the INVITE tx is still present; route by branch. + try: + 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: try: - tx = self.transactions[request.branch] + tx = self._transactions[request.branch] except KeyError: self.send( Response.from_request( @@ -271,6 +324,7 @@ def request_received(self, request: Request) -> None: ) ) return + tx.cancel_received(request) case SIPMethod.OPTIONS: self.send( Response.from_request( @@ -280,17 +334,8 @@ def request_received(self, request: Request) -> None: headers={"Allow": self.allow_header}, ) ) - return case _: - tx = self.transaction_class.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. @@ -299,7 +344,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", @@ -309,6 +354,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. @@ -325,14 +377,14 @@ 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": 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..a941c44 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 @@ -24,7 +25,7 @@ from ..types import NetworkAddress from . import messages, types -from .messages import Dialog, Request, Response +from .messages import Request, Response, SIPHeaderDict from .types import ( CallerID, DigestAlgorithm, @@ -34,26 +35,30 @@ ) if typing.TYPE_CHECKING: + from .dialog import Dialog from .protocol import SessionInitiationProtocol logger = logging.getLogger("voip.sip") __all__ = [ + "ByeTransaction", "InviteTransaction", "RegistrationTransaction", ] @dataclasses.dataclass(kw_only=True, slots=True) -class Transaction: +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 and resolves to the dialog. + 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" @@ -75,6 +80,7 @@ class Transaction: ) def __post_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}") @@ -82,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.local_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}", } @@ -93,6 +99,27 @@ 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 + async def receive( + cls, + *, + request: Request, + sip: SessionInitiationProtocol, + ): ... + + @classmethod + async def send( + cls, + *, + sip: SessionInitiationProtocol, + **kwargs: typing.Any, + ): ... + @classmethod def from_request( cls, @@ -101,9 +128,9 @@ 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 = Dialog.from_request(request) + dialog = sip.dialog_class.from_request(request) return cls( sip=sip, dialog=dialog, @@ -134,6 +161,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 @@ -152,7 +181,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, ) @@ -164,10 +193,11 @@ def response_received(self, response: Response) -> None: Args: response: The parsed SIP response. """ - self.sip.transactions.pop(self.branch) + self.sip.drop_transaction(self) match response.status_code: case SIPStatus.OK: logger.info("Registration successful") + self.set_result(self.dialog) return case SIPStatus.UNAUTHORIZED | SIPStatus.PROXY_AUTHENTICATION_REQUIRED: logger.debug( @@ -230,12 +260,23 @@ def response_received(self, response: Response) -> None: method=self.method, authorization=auth_value, ) - self.sip.transactions[tx.branch] = tx + self.sip.register_transaction(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. @@ -316,83 +357,83 @@ 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.Dialog] + and override [call_received][voip.sip.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(session_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 """ - def invite_received(self, request: Request) -> None: - """Handle the incoming call. + 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 + ) + + @classmethod + async def receive( + cls, + *, + request: Request, + sip: SessionInitiationProtocol, + ) -> Dialog: + """Handle an incoming INVITE [RFC 3261 §13.3]. - 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. + 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 """ + tx = cls.from_request(request=request, sip=sip) + sip.register_transaction(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. Override - in subclasses to react to the ACK. + 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) - - def bye_received(self, request: Request) -> None: - """Handle a BYE terminating a dialog. - - Override in subclasses to tear down the call. - - 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.sip.drop_transaction(self) + self.complete() 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. """ - self.sip.transactions.pop(self.branch) - self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag)) + self.sip.drop_transaction(self) + self.sip.drop_dialog(self.dialog) self.send_response( Response.from_request( request, @@ -401,6 +442,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]. @@ -436,20 +479,24 @@ 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: - Call from within `call_received`: + Call from within [Dialog.call_received][voip.sip.Dialog.call_received] + via [Dialog.accept][voip.sip.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: - 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: @@ -471,7 +518,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", @@ -483,13 +530,20 @@ def call_received(self) -> None: use_srtp = negotiated_media.proto == "RTP/SAVP" srtp_session = SRTPSession.generate() if use_srtp else None - call_handler = call_class( + 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.register_dialog(self.dialog) + + call_handler = session_class( rtp=self.sip.rtp, - sip=self.sip, caller=caller, media=negotiated_media, srtp=srtp_session, - **call_kwargs, + dialog=self.dialog, + **session_kwargs, ) if remote_audio is not None and remote_audio.port != 0: media_connection = remote_audio.connection @@ -513,18 +567,16 @@ 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( 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, - dialog=dialog, + dialog=self.dialog, status_code=SIPStatus.OK, phrase=SIPStatus.OK.phrase, headers={ @@ -566,22 +618,327 @@ def call_received(self) -> None: ) ) - async def make_call( - self, - target: str, + @classmethod + async def send( + cls, *, - call_class: type[Session], - **call_kwargs: typing.Any, - ) -> Request: - """Initiate an outgoing call to `target`. + sip: SessionInitiationProtocol, + target: types.SipURI, + dialog: Dialog, + session_class: type[Session], + **session_kwargs: typing.Any, + ) -> Dialog: + """Initiate an outgoing call to *target* [RFC 3261 §13.1]. Args: - target: SIP URI of the callee (e.g. ``"sip:bob@example.com"``). - call_class: Session implementation that will be initialized for the call. - **call_kwargs: Additional keyword arguments forwarded to the + sip: The SIP session to send from. + 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 call class constructor. - Raises: - NotImplementedError: Not yet implemented. + Returns: + The dialog once the call is established (ACK sent). + + [RFC 3261 §13.1]: https://datatracker.ietf.org/doc/html/rfc3261#section-13.1 + """ + if dialog.uac is None: + dialog.uac = sip.aor + dialog.sip = 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 = 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=session_class.sdp_formats(), + attributes=[Attribute(name="sendrecv")], + ) + ], + ) + tx.request = Request( + method=SIPMethod.INVITE, + uri=target, + headers={ + "Max-Forwards": "70", + **tx.headers, + "From": dialog.from_header, + "To": str(target), + "Contact": sip.contact, + "Call-ID": dialog.call_id, + "Route": f"", + "Allow": sip.allow_header, + "Content-Type": "application/sdp", + }, + body=sdp_offer, + ) + sip.register_transaction(tx) + sip.send(tx.request) + try: + return await tx + except asyncio.CancelledError: + sip.drop_transaction(tx) + raise + + def response_received(self, response: Response) -> None: + """Dispatch responses to an outbound INVITE.""" + match response.status_code // 100: + case 1: # trying/ringing + return + case 2: # OK + self._start_call(response) + self.ack(response) + self.complete() + + 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. + + Args: + response: The 200 OK SIP response containing the remote SDP answer. + """ + 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, + 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: + 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) + + def ack(self, response: Response) -> None: + """Send an ACK after receiving a terminal response. + + For 2xx responses, establishes the dialog and registers it with the + protocol. + """ + 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.register_dialog(self.dialog) + self.sip.drop_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) + ) + self.dialog.remote_contact = ack_uri + ack_headers: SIPHeaderDict = SIPHeaderDict( + { + "Via": ( + f"SIP/2.0/{self.sip.aor.transport}" + f" {self.sip.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 route in self.dialog.route_set: + ack_headers.add("Route", route) + self.sip.send( + Request( + method=SIPMethod.ACK, + uri=ack_uri, + headers=ack_headers, + ) + ) + + +@dataclasses.dataclass(kw_only=True, slots=True) +class ByeTransaction(Transaction): + """BYE transaction for terminating a dialog [RFC 3261 §15, §17.1.2]. + + 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 + + @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/{sip.aor.transport}" + f' {sip.rtp.public_address};oc-algo="loss";oc;rport;branch={tx.branch}' + ), + "Max-Forwards": "70", + "From": dialog.local_party, + "To": dialog.remote_party, + "Call-ID": dialog.call_id, + "CSeq": f"{cseq} {SIPMethod.BYE}", + "Content-Length": "0", + } + ) + for route in dialog.route_set: + headers.add("Route", route) + tx.request = Request(method=SIPMethod.BYE, uri=request_uri, headers=headers) + sip.register_transaction(tx) + sip.send(tx.request) + try: + return await tx + except asyncio.CancelledError: + sip.drop_transaction(tx) + 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.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.drop_dialog(tx.dialog) + 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 200 OK for an outgoing BYE [RFC 3261 §15.1.1]. + + Args: + response: The parsed SIP response to our BYE request. """ - raise NotImplementedError("make_call is not yet implemented") + if response.status_code >= 200: + self.sip.drop_transaction(self) + self.complete() + logger.debug( + "BYE acknowledged: %s %s", response.status_code, response.phrase + ) diff --git a/voip/sip/types.py b/voip/sip/types.py index 20fed8a..cd5a886 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -1,4 +1,3 @@ -import dataclasses import enum import ipaddress import re @@ -7,9 +6,10 @@ "CallerID", "DigestAlgorithm", "DigestQoP", - "SipUri", + "SipURI", "SIPStatus", "SIPMethod", + "parse_uri", ] import typing @@ -22,58 +22,44 @@ pass -@dataclasses.dataclass(slots=True, eq=True) -class SipUri: +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` - 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', ...) - >>> SipUri.parse("sips:+15551234567@carrier.com:5061") - SipUri(scheme='sips', user='+15551234567', host='carrier.com', port=5061, ...) - >>> SipUri.parse("sip:alice@[::1]:5060") - SipUri(scheme='sip', user='alice', host=IPv6Address('::1'), port=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). - 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. + >>> SipURI.parse("sip:alice@example.com") + 'sip:alice@example.com:5060' + >>> SipURI.parse("sips:+15551234567@carrier.com:5061") + 'sips:%2B15551234567@carrier.com:5061' + >>> SipURI.parse("sip:alice@[::1]:5060") + 'sip:alice@[::1]:5060' """ - 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 + __slots__ = ("scheme", "host", "user", "password", "port", "parameters", "headers") SIP_URL_PATTERN: typing.ClassVar[re.Pattern[str]] = re.compile( r"^(?Psips?):" @@ -85,8 +71,59 @@ 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, + 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: + def parse(cls, value: str) -> SipURI: """ Parse a SIP or SIPS URI string into a `SipUri` instance. @@ -140,34 +177,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: @@ -215,17 +224,23 @@ 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 | None: + """Parsed SIP or tel URI embedded in the header value, if present.""" + if m := re.search(r"\s]+)>?", self): + return SipURI.parse(m.group(1)) + @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 + if m := re.search(r"sips?:([^@>;\s]+)@", self): + return m.group(1) @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 + if m := re.search(r"sips?:[^@>;\s]+@([^>;)\s,]+)", self): + return m.group(1) @property def tag(self) -> str | None: @@ -241,250 +256,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): 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))