From aefaa8e976af0f03c119fb8323f887d5575e446a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 14:12:15 +0000 Subject: [PATCH 1/8] Initial plan From ff9e06b37a46a2c34a6c5ece66c9841acec2e963 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 14:32:51 +0000 Subject: [PATCH 2/8] Changes before error encountered Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/73800b30-637b-4995-bc8e-135c5bc3cb21 Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> --- docs/mcp.md | 2 + docs/sessions.md | 9 +- tests/test_fax.py | 257 +++++++++++++++++++++++++++++++++++++++ voip/__main__.py | 35 ++++++ voip/fax.py | 143 ++++++++++++++++++++++ voip/mcp.py | 35 ++++++ voip/rtp.py | 44 ++++++- voip/sdp/types.py | 27 ++-- voip/sip/transactions.py | 45 ++++--- 9 files changed, 557 insertions(+), 40 deletions(-) create mode 100644 tests/test_fax.py create mode 100644 voip/fax.py diff --git a/docs/mcp.md b/docs/mcp.md index 8daa3e4..197e985 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -31,6 +31,8 @@ Set `SIP_AOR` to your SIP address-of-record. ::: voip.mcp.say +::: voip.mcp.send_fax + ::: voip.mcp.call [cc-mcp]: https://docs.anthropic.com/en/docs/claude-code/mcp diff --git a/docs/sessions.md b/docs/sessions.md index 92f3dc9..240b17d 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -3,7 +3,8 @@ [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. +Sessions can be audio, video, and more. This library provides audio sessions via +[AudioCall][voip.audio.AudioCall] and T.38 FAX sessions via [FaxSession][voip.fax.FaxSession]. ::: voip.rtp.Session @@ -22,3 +23,9 @@ Sessions can be audio, video, and more. However, this library currently only pro ::: voip.ai.AgentCall ::: voip.ai.SayCall + +## FAX (T.38) + +::: voip.fax.FaxSession + +::: voip.fax.FaxCall diff --git a/tests/test_fax.py b/tests/test_fax.py new file mode 100644 index 0000000..7d7c1c2 --- /dev/null +++ b/tests/test_fax.py @@ -0,0 +1,257 @@ +"""Tests for the T.38 FAX session (voip.fax).""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from voip.fax import FaxCall, FaxSession +from voip.rtp import RealtimeTransportProtocol +from voip.sdp.types import MediaDescription, RTPPayloadFormat +from voip.sip.dialog import Dialog +from voip.sip.types import CallerID +from voip.types import NetworkAddress + + +def make_fax_session(**kwargs) -> FaxSession: + """Create a `FaxSession` with sensible mock defaults.""" + defaults: dict = { + "rtp": MagicMock(spec=RealtimeTransportProtocol), + "dialog": MagicMock(spec=Dialog), + "media": FaxSession.sdp_media_description(), + "caller": CallerID(""), + } + defaults.update(kwargs) + return FaxSession(**defaults) + + +# --------------------------------------------------------------------------- +# FaxSession class attributes +# --------------------------------------------------------------------------- + + +class TestFaxSessionAttributes: + def test_media_type__is_image(self) -> None: + """media_type class variable is 'image' for T.38.""" + assert FaxSession.media_type == "image" + + def test_t38_version__is_zero(self) -> None: + """T38_VERSION defaults to 0.""" + assert FaxSession.T38_VERSION == 0 + + def test_t38_max_bit_rate__is_14400(self) -> None: + """T38_MAX_BIT_RATE defaults to 14400 bps.""" + assert FaxSession.T38_MAX_BIT_RATE == 14400 + + +# --------------------------------------------------------------------------- +# FaxSession.sdp_formats +# --------------------------------------------------------------------------- + + +class TestSdpFormats: + def test_sdp_formats__returns_t38_format(self) -> None: + """sdp_formats returns a single T.38 payload format.""" + formats = FaxSession.sdp_formats() + assert len(formats) == 1 + assert formats[0].payload_type == "t38" + + +# --------------------------------------------------------------------------- +# FaxSession.sdp_media_description +# --------------------------------------------------------------------------- + + +class TestSdpMediaDescription: + def test_sdp_media_description__media_is_image(self) -> None: + """sdp_media_description produces an m=image section.""" + assert FaxSession.sdp_media_description(port=5004).media == "image" + + def test_sdp_media_description__proto_is_udptl(self) -> None: + """sdp_media_description uses udptl transport.""" + assert FaxSession.sdp_media_description().proto == "udptl" + + def test_sdp_media_description__port_is_set(self) -> None: + """sdp_media_description includes the provided port.""" + assert FaxSession.sdp_media_description(port=9876).port == 9876 + + def test_sdp_media_description__includes_t38_fax_version(self) -> None: + """sdp_media_description includes the T38FaxVersion attribute.""" + attributes = FaxSession.sdp_media_description().attributes + assert any(a.name == "T38FaxVersion" for a in attributes) + + def test_sdp_media_description__includes_t38_max_bit_rate(self) -> None: + """sdp_media_description includes the T38MaxBitRate attribute.""" + attributes = FaxSession.sdp_media_description().attributes + assert any(a.name == "T38MaxBitRate" for a in attributes) + + def test_sdp_media_description__includes_rate_management(self) -> None: + """sdp_media_description includes T38FaxRateManagement.""" + attributes = FaxSession.sdp_media_description().attributes + assert any(a.name == "T38FaxRateManagement" for a in attributes) + + def test_sdp_media_description__default_port_is_zero(self) -> None: + """sdp_media_description uses port 0 when no port is given.""" + assert FaxSession.sdp_media_description().port == 0 + + +# --------------------------------------------------------------------------- +# FaxSession.negotiate_codec +# --------------------------------------------------------------------------- + + +class TestNegotiateCodec: + def test_negotiate_codec__accepts_t38_offer(self) -> None: + """negotiate_codec returns T.38 MediaDescription for a valid T.38 offer.""" + offer = MediaDescription( + media="image", + port=5004, + proto="udptl", + fmt=[RTPPayloadFormat(payload_type="t38")], + ) + result = FaxSession.negotiate_codec(offer) + assert result.media == "image" + assert result.proto == "udptl" + + def test_negotiate_codec__raises_for_non_t38_offer(self) -> None: + """negotiate_codec raises NotImplementedError when T.38 is absent.""" + offer = MediaDescription( + media="image", + port=5004, + proto="udptl", + fmt=[RTPPayloadFormat(payload_type=0)], + ) + with pytest.raises(NotImplementedError, match="T.38"): + FaxSession.negotiate_codec(offer) + + def test_negotiate_codec__uses_remote_port(self) -> None: + """negotiate_codec returns a description with the remote offer's port.""" + offer = MediaDescription( + media="image", + port=7070, + proto="udptl", + fmt=[RTPPayloadFormat(payload_type="t38")], + ) + assert FaxSession.negotiate_codec(offer).port == 7070 + + +# --------------------------------------------------------------------------- +# FaxSession.data_received +# --------------------------------------------------------------------------- + + +class TestDataReceived: + def test_data_received__delegates_to_document_received(self) -> None: + """data_received forwards the raw data to document_received.""" + received: list[bytes] = [] + session = make_fax_session() + session.document_received = received.append + session.data_received(b"t38 packet", NetworkAddress("127.0.0.1", 5004)) + assert received == [b"t38 packet"] + + +# --------------------------------------------------------------------------- +# FaxSession.document_received +# --------------------------------------------------------------------------- + + +class TestDocumentReceived: + def test_document_received__is_noop(self) -> None: + """document_received is a no-op in the base class.""" + make_fax_session().document_received(b"data") # must not raise + + +# --------------------------------------------------------------------------- +# FaxSession.send_document +# --------------------------------------------------------------------------- + + +class TestSendDocument: + def test_send_document__sends_to_remote_address(self) -> None: + """send_document transmits data to the registered remote address.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + session = make_fax_session(rtp=mock_rtp) + remote_address = ("127.0.0.1", 5004) + mock_rtp.calls = {remote_address: session} + session.send_document(b"fax content") + mock_rtp.send.assert_called_once_with(b"fax content", remote_address) + + def test_send_document__logs_warning_when_no_remote_address( + self, caplog + ) -> None: + """send_document logs a warning and does nothing when not registered.""" + import logging # noqa: PLC0415 + + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + session = make_fax_session(rtp=mock_rtp) + mock_rtp.calls = {} + with caplog.at_level(logging.WARNING, logger="voip.fax"): + session.send_document(b"fax content") + mock_rtp.send.assert_not_called() + assert any("No remote address" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# FaxCall +# --------------------------------------------------------------------------- + + +class TestFaxCall: + async def test_transmit__sends_document_and_hangs_up(self) -> None: + """transmit sends the document, hangs up, and closes the SIP connection.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_dialog = MagicMock(spec=Dialog) + mock_sip = MagicMock() + mock_dialog.sip = mock_sip + remote_address = ("127.0.0.1", 5004) + + session = FaxCall( + rtp=mock_rtp, + dialog=mock_dialog, + media=FaxSession.sdp_media_description(), + caller=CallerID(""), + document=b"pdf bytes", + ) + mock_rtp.calls = {remote_address: session} + + with patch.object(session, "hang_up", new_callable=AsyncMock) as mock_hang_up: + await session.transmit() + + mock_rtp.send.assert_called_once_with(b"pdf bytes", remote_address) + mock_hang_up.assert_awaited_once() + mock_sip.close.assert_called_once() + + async def test_transmit__skips_sip_close_when_no_sip(self) -> None: + """transmit does not raise when dialog.sip is None.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_dialog = MagicMock(spec=Dialog) + mock_dialog.sip = None + mock_rtp.calls = {} + + session = FaxCall( + rtp=mock_rtp, + dialog=mock_dialog, + media=FaxSession.sdp_media_description(), + caller=CallerID(""), + document=b"pdf", + ) + + with patch.object(session, "hang_up", new_callable=AsyncMock): + await session.transmit() # must not raise + + def test_post_init__creates_transmit_task(self) -> None: + """__post_init__ schedules transmit() as an asyncio task.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_dialog = MagicMock(spec=Dialog) + mock_dialog.sip = None + + with patch("asyncio.create_task") as mock_create_task: + FaxCall( + rtp=mock_rtp, + dialog=mock_dialog, + media=FaxSession.sdp_media_description(), + caller=CallerID(""), + document=b"pdf", + ) + + mock_create_task.assert_called_once() diff --git a/voip/__main__.py b/voip/__main__.py index 7813899..fdb7c6c 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -9,6 +9,7 @@ import time from voip.ai import SayCall +from voip.fax import FaxCall from voip.rtp import RealtimeTransportProtocol, Session from voip.sip import dialog, messages from voip.sip.protocol import SessionInitiationProtocol @@ -562,6 +563,40 @@ async def run(): pass +@sip.command() +@click.argument("target") +@click.argument("document", type=click.Path(exists=True, readable=True)) +@click.pass_context +def fax(ctx, target: str, document: str): + """Dial TARGET and send DOCUMENT as a T.38 FAX.""" + obj = ctx.obj + aor = obj["aor"] + + async def run(): + _, rtp_protocol = await _connect_rtp( + aor.maddr, + obj["stun_server"], + ) + await _connect_sip_once( + _make_outbound_factory( + verbose=obj.get("verbose", 0), + aor=aor, + rtp_protocol=rtp_protocol, + target_uri=parse_uri(target, aor), + session_class=FaxCall, + session_kwargs={"document": open(document, "rb").read()}, # noqa: WPS515 + ), + aor.maddr, + aor.transport == "TLS", + obj["no_verify_tls"], + ) + + try: + asyncio.run(run()) + except KeyboardInterrupt: + pass + + @sip.command() @click.argument("target") @click.argument("prompt") diff --git a/voip/fax.py b/voip/fax.py new file mode 100644 index 0000000..dc70078 --- /dev/null +++ b/voip/fax.py @@ -0,0 +1,143 @@ +"""T.38 FAX over SIP/UDPTL session. + +Implements SIP signaling and UDPTL media transport for sending and receiving +fax documents over IP using the T.38 protocol. + +[RFC 3362]: https://datatracker.ietf.org/doc/html/rfc3362 +""" + +import asyncio +import dataclasses +import logging +from typing import ClassVar + +from voip.rtp import Session +from voip.sdp.types import Attribute, MediaDescription, RTPPayloadFormat +from voip.types import NetworkAddress + +__all__ = ["FaxCall", "FaxSession"] + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(kw_only=True) +class FaxSession(Session): + """T.38 FAX over SIP/UDPTL session [RFC 3362]. + + Handles SIP signaling and UDPTL media transport for sending and receiving + fax documents. Override + [document_received][voip.fax.FaxSession.document_received] to process + incoming fax data, and use + [send_document][voip.fax.FaxSession.send_document] to transmit outbound + data. + + Attributes: + T38_VERSION: T.38 protocol version advertised in SDP. + T38_MAX_BIT_RATE: Maximum fax bit rate in bits per second. + + [RFC 3362]: https://datatracker.ietf.org/doc/html/rfc3362 + """ + + media_type: ClassVar[str] = "image" + T38_VERSION: ClassVar[int] = 0 + T38_MAX_BIT_RATE: ClassVar[int] = 14400 + + def data_received(self, data: bytes, address: NetworkAddress) -> None: + """Handle an incoming T.38 UDPTL packet. + + Args: + data: Raw UDPTL packet bytes. + address: Source ``(host, port)`` of the datagram. + """ + self.document_received(data) + + def document_received(self, data: bytes) -> None: + """Handle received FAX document data. + + Override in subclasses to process the received T.38 UDPTL data. + + Args: + data: Raw T.38 UDPTL data. + """ + + def send_document(self, data: bytes) -> None: + """Send a fax document as T.38 UDPTL data. + + Args: + data: Raw document data to send. + """ + remote_address = next( + (address for address, call in self.rtp.calls.items() if call is self), + None, + ) + if remote_address is None: + logger.warning("No remote address for FAX call; dropping document data") + return + self.rtp.send(data, remote_address) + + @classmethod + def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: + """Negotiate T.38 from a remote SDP ``m=image`` offer. + + Args: + remote_media: The SDP ``m=image`` section from the remote INVITE. + + Returns: + A T.38 `MediaDescription` for the response SDP. + + Raises: + NotImplementedError: When the remote offer does not include T.38. + """ + if not any(str(fmt.payload_type).lower() == "t38" for fmt in remote_media.fmt): + raise NotImplementedError("Remote SDP offer does not include T.38") + return cls.sdp_media_description(port=remote_media.port) + + @classmethod + def sdp_formats(cls) -> list[RTPPayloadFormat]: + """Return the T.38 payload format descriptor.""" + return [RTPPayloadFormat(payload_type="t38")] + + @classmethod + def sdp_media_description(cls, port: int = 0) -> MediaDescription: + """Return the T.38 media description for outbound SDP. + + Args: + port: Local UDP port for T.38 UDPTL transport. + + Returns: + A `MediaDescription` with T.38/UDPTL parameters per [RFC 3362]. + + [RFC 3362]: https://datatracker.ietf.org/doc/html/rfc3362 + """ + return MediaDescription( + media="image", + port=port, + proto="udptl", + fmt=[RTPPayloadFormat(payload_type="t38")], + attributes=[ + Attribute(name="T38FaxVersion", value=str(cls.T38_VERSION)), + Attribute(name="T38MaxBitRate", value=str(cls.T38_MAX_BIT_RATE)), + Attribute(name="T38FaxRateManagement", value="transferredTCF"), + ], + ) + + +@dataclasses.dataclass(kw_only=True, slots=True) +class FaxCall(FaxSession): + """Dial a number, send a FAX document, and hang up. + + Attributes: + document: Raw document bytes to transmit as a T.38 FAX. + """ + + document: bytes + + def __post_init__(self) -> None: + asyncio.create_task(self.transmit()) + + async def transmit(self) -> None: + """Send the document and hang up when transmission completes.""" + self.send_document(self.document) + await self.hang_up() + if self.dialog is not None and self.dialog.sip is not None: + self.dialog.sip.close() diff --git a/voip/mcp.py b/voip/mcp.py index 17abbeb..068c3a2 100644 --- a/voip/mcp.py +++ b/voip/mcp.py @@ -8,12 +8,15 @@ import threading import typing +import pathlib + from fastmcp import Context, FastMCP from mcp.types import SamplingMessage, TextContent import voip from voip import ai from voip.ai import SayCall +from voip.fax import FaxCall from voip.sip import Dialog from voip.sip.protocol import SessionInitiationProtocol from voip.sip.types import SipURI, parse_uri @@ -101,6 +104,38 @@ async def say(ctx: Context, target: str, prompt: str = "") -> None: await dialog.dial(target_uri, session_class=SayCall, text=prompt) +@mcp.tool +async def send_fax( + ctx: Context, + target: str, + text: str = "", + document_path: str = "", +) -> None: + """Send a T.38 FAX to a phone number. + + Dials `target` and transmits either the file at `document_path` or + `text` (encoded as UTF-8) as a T.38 FAX, then hangs up. + When both are provided, `document_path` takes precedence. + + Args: + ctx: FastMCP context (injected automatically by the framework). + target: Phone number or SIP URI to call, e.g. `"tel:+1234567890"` + or `"sip:alice@example.com"`. + text: Plain-text content to send as a FAX. + document_path: Path to a document file (e.g. PDF) to send as a FAX. + """ + if not hasattr(connection_pool, "sip"): + raise RuntimeError("VoIP not connected: call run() before using tools.") + if not document_path and not text: + raise ValueError("Provide either text or document_path.") + document = ( + pathlib.Path(document_path).read_bytes() if document_path else text.encode() + ) + target_uri = parse_uri(target, connection_pool.sip.aor) + dialog = Dialog(sip=connection_pool.sip) + await dialog.dial(target_uri, session_class=FaxCall, document=document) + + @mcp.tool async def call( ctx: Context, diff --git a/voip/rtp.py b/voip/rtp.py index fdb99ad..77d5772 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -13,7 +13,7 @@ import typing from typing import TYPE_CHECKING -from voip.sdp.types import MediaDescription, RTPPayloadFormat +from voip.sdp.types import Attribute, MediaDescription, RTPPayloadFormat from voip.srtp import SRTPSession from voip.stun import STUNProtocol from voip.types import ByteSerializableObject, NetworkAddress @@ -102,6 +102,7 @@ class Session: negotiation, buffering, and decoding. Attributes: + media_type: SDP media type (e.g. ``"audio"`` or ``"image"``). rtp: Shared RTP multiplexer socket that delivers packets to this handler. dialog: SIP dialog state for this call leg. media: Negotiated SDP media description for this call leg. @@ -109,6 +110,8 @@ class Session: srtp: Optional SRTP session for encrypting and decrypting media. """ + media_type: typing.ClassVar[str] = "audio" + rtp: RealtimeTransportProtocol dialog: Dialog media: MediaDescription @@ -123,6 +126,18 @@ def packet_received(self, packet: RTPPacket, addr: NetworkAddress) -> None: addr: Remote ``(host, port)`` the packet arrived from. """ + def data_received(self, data: bytes, addr: NetworkAddress) -> None: + """Handle a raw datagram for non-RTP media protocols. + + Called when an incoming datagram cannot be parsed as an RTP packet. + Override in subclasses to support alternative media transports such as + UDPTL (T.38 FAX). + + Args: + data: Raw datagram payload. + addr: Source ``(host, port)`` of the datagram. + """ + def send_packet(self, packet: RTPPacket, addr: NetworkAddress) -> None: """Serialize *packet* and send it via the shared RTP socket. @@ -202,6 +217,27 @@ def sdp_formats(cls) -> list[RTPPayloadFormat]: return [RTPPayloadFormat.from_pt(StaticPayloadType.PCMU.pt)] + @classmethod + def sdp_media_description(cls, port: int) -> MediaDescription: + """Return the media description for outbound SDP offers. + + Override in subclasses to support alternative media types (e.g. T.38 + FAX uses ``m=image udptl t38`` instead of ``m=audio RTP/AVP``). + + Args: + port: Local port number for the media stream. + + Returns: + A `MediaDescription` suitable for inclusion in an SDP offer. + """ + return MediaDescription( + media=cls.media_type, + port=port, + proto="RTP/AVP", + fmt=cls.sdp_formats(), + attributes=[Attribute(name="sendrecv")], + ) + @dataclasses.dataclass(kw_only=True, slots=True) class RealtimeTransportProtocol(STUNProtocol): @@ -307,11 +343,7 @@ def packet_received(self, data: bytes, addr: NetworkAddress) -> None: try: handler.packet_received(RTPPacket.parse(data), addr) except ValueError: - logger.warning( - "Malformed RTP packet from %s:%s, discarding", - addr[0], - addr[1], - ) + handler.data_received(data, addr) else: logger.debug( "No call handler registered for %s:%s, dropping RTP packet", diff --git a/voip/sdp/types.py b/voip/sdp/types.py index bfba645..572115d 100644 --- a/voip/sdp/types.py +++ b/voip/sdp/types.py @@ -272,13 +272,15 @@ class RTPPayloadFormat(ByteSerializableObject): Serialises to the ``a=rtpmap`` value when codec fields are present. """ - payload_type: int + payload_type: int | str fmtp: str | None = None encoding_name: str | None = None channels: int = 1 sample_rate: int | None = None def __post_init__(self): + if not isinstance(self.payload_type, int): + return try: default = StaticPayloadType.from_pt(self.payload_type) except ValueError: @@ -323,12 +325,13 @@ def frame_size(self) -> int: For dynamic payload types (e.g. Opus, PT ≥ 96) it is derived from `sample_rate` assuming a 20 ms packetisation interval. """ - try: - spec = StaticPayloadType.from_pt(self.payload_type) - if spec.frame_size: - return spec.frame_size - except ValueError: - pass + if isinstance(self.payload_type, int): + try: + spec = StaticPayloadType.from_pt(self.payload_type) + if spec.frame_size: + return spec.frame_size + except ValueError: + pass return (self.sample_rate or 8000) * 20 // 1000 @@ -352,7 +355,10 @@ class MediaDescription(ByteSerializableObject): def get_format(self, pt: int | str) -> RTPPayloadFormat | None: """Return the `RTPPayloadFormat` for payload type *pt*, or ``None``.""" - target = int(pt) + try: + target: int | str = int(pt) + except (TypeError, ValueError): + target = str(pt) return next((f for f in self.fmt if f.payload_type == target), None) def apply_attribute(self, attr: Attribute) -> bool: @@ -411,7 +417,10 @@ def parse(cls, data: bytes | str) -> MediaDescription: lines = value.splitlines() first = lines[0].rstrip("\r").removeprefix("m=") media_type, port_str, proto, *fmts = first.split() - fmt = [RTPPayloadFormat.from_pt(int(pt)) for pt in fmts] + fmt = [ + RTPPayloadFormat.from_pt(int(pt)) if pt.isdigit() else RTPPayloadFormat(payload_type=pt) + for pt in fmts + ] obj = cls(media=media_type, port=int(port_str), proto=proto, fmt=fmt) for line in lines[1:]: line = line.rstrip("\r") diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index d515dfd..dbec574 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -509,19 +509,19 @@ def call_received(self) -> None: else None ) caller = CallerID(self.request.headers.get("From", "")) - remote_audio = next( + remote_media = next( ( m for m in (self.request.body.media if self.request.body else []) - if m.media == "audio" + if m.media == session_class.media_type ), None, ) - if remote_audio is not None: - negotiated_media = session_class.negotiate_codec(remote_audio) + if remote_media is not None: + negotiated_media = session_class.negotiate_codec(remote_media) else: negotiated_media = MediaDescription( - media="audio", + media=session_class.media_type, port=0, proto="RTP/SAVP", fmt=[RTPPayloadFormat.from_pt(0)], @@ -545,8 +545,8 @@ def call_received(self) -> None: dialog=self.dialog, **session_kwargs, ) - if remote_audio is not None and remote_audio.port != 0: - media_connection = remote_audio.connection + if remote_media is not None and remote_media.port != 0: + media_connection = remote_media.connection session_connection = ( self.request.body.connection if self.request.body else None ) @@ -556,7 +556,7 @@ def call_received(self) -> None: else: remote_ip = peer[0] if peer else "0.0.0.0" # noqa: S104 remote_rtp_address: NetworkAddress | None = NetworkAddress( - remote_ip, remote_audio.port + remote_ip, remote_media.port ) else: remote_rtp_address = None @@ -568,7 +568,10 @@ 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.public_address - sdp_media_attributes = [Attribute(name="sendrecv")] + sdp_media_attributes = list( + negotiated_media.attributes if negotiated_media.attributes + else [Attribute(name="sendrecv")] + ) if srtp_session is not None: sdp_media_attributes.append( Attribute(name="crypto", value=srtp_session.sdes_attribute) @@ -607,7 +610,7 @@ def call_received(self) -> None: ), media=[ MediaDescription( - media="audio", + media=negotiated_media.media, port=rtp_public[1], proto=negotiated_media.proto, fmt=negotiated_media.fmt, @@ -678,13 +681,7 @@ async def send( 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")], - ) + session_class.sdp_media_description(rtp_public[1]) ], ) tx.request = Request( @@ -735,16 +732,16 @@ def _start_call(self, response: Response) -> None: if self.sip.transport else None ) - remote_audio = next( + remote_media = next( ( m for m in (response.body.media if response.body else []) - if m.media == "audio" + if m.media == self.pending_call_class.media_type ), 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) + if remote_media is not None and self.pending_call_class is not None: + negotiated_media = self.pending_call_class.negotiate_codec(remote_media) else: negotiated_media = MediaDescription( media="audio", @@ -762,8 +759,8 @@ def _start_call(self, response: Response) -> None: dialog=self.dialog, **self.pending_call_kwargs, ) - if remote_audio is not None and remote_audio.port != 0: - media_connection = remote_audio.connection + if remote_media is not None and remote_media.port != 0: + media_connection = remote_media.connection session_connection = response.body.connection if response.body else None connection = media_connection or session_connection remote_ip = ( @@ -774,7 +771,7 @@ def _start_call(self, response: Response) -> None: else None ) remote_rtp_address: NetworkAddress | None = ( - NetworkAddress(remote_ip, remote_audio.port) + NetworkAddress(remote_ip, remote_media.port) if remote_ip is not None else None ) From 18bf3536d14650b2f84ecdc1f6855b0c50e8da7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:10:44 +0000 Subject: [PATCH 3/8] Add missing coverage tests for rtp, sdp/types, and mcp send_fax --- tests/sdp/test_messages.py | 106 ++++++++++++++++++++++++++++++++++++ tests/test_mcp.py | 108 +++++++++++++++++++++++++++++++++++++ tests/test_rtp.py | 42 ++++++++++++++- 3 files changed, 255 insertions(+), 1 deletion(-) diff --git a/tests/sdp/test_messages.py b/tests/sdp/test_messages.py index 1ba1ec8..6c2d434 100644 --- a/tests/sdp/test_messages.py +++ b/tests/sdp/test_messages.py @@ -759,3 +759,109 @@ def test_sample_rate__unknown_dynamic_pt__returns_none(self): """Dynamic PTs without an a=rtpmap have sample_rate=None (no RFC 3551 default).""" f = RTPPayloadFormat(payload_type=99) assert f.sample_rate is None + + +class TestRTPPayloadFormatPostInit: + def test_post_init__str_payload_type__skips_static_defaults(self) -> None: + """String payload types (e.g. 't38') skip StaticPayloadType defaults.""" + f = RTPPayloadFormat(payload_type="t38") + assert f.encoding_name is None + assert f.sample_rate is None + + +class TestRTPPayloadFormatFrameSize: + def test_frame_size__static_pcmu(self) -> None: + """frame_size returns the RFC 3551 frame size for PCMU (PT 0).""" + assert RTPPayloadFormat(payload_type=0).frame_size == 160 + + def test_frame_size__dynamic_with_sample_rate(self) -> None: + """frame_size is derived from sample_rate for dynamic payload types.""" + f = RTPPayloadFormat( + payload_type=111, encoding_name="opus", sample_rate=48000, channels=2 + ) + assert f.frame_size == 48000 * 20 // 1000 + + def test_frame_size__dynamic_without_sample_rate__uses_8000_default(self) -> None: + """frame_size falls back to 8000 Hz when sample_rate is None.""" + f = RTPPayloadFormat(payload_type=99) + assert f.frame_size == 8000 * 20 // 1000 + + def test_frame_size__str_payload_type__uses_sample_rate_fallback(self) -> None: + """String payload types bypass StaticPayloadType and use sample_rate fallback.""" + f = RTPPayloadFormat(payload_type="t38") + assert f.frame_size == 8000 * 20 // 1000 + + +class TestMediaDescriptionGetFormatNonInt: + def test_get_format__non_numeric_string__matches_str_payload_type(self) -> None: + """get_format with a non-numeric string matches str payload types.""" + media = MediaDescription( + media="image", + port=0, + proto="udptl", + fmt=[RTPPayloadFormat(payload_type="t38")], + ) + assert media.get_format("t38") is not None + + def test_get_format__non_numeric_string__returns_none_when_not_found(self) -> None: + """get_format returns None for an unrecognised non-numeric string.""" + media = MediaDescription( + media="image", + port=0, + proto="udptl", + fmt=[RTPPayloadFormat(payload_type="t38")], + ) + assert media.get_format("vp8") is None + + +class TestMediaDescriptionApplyAttributeEdgeCases: + def test_apply_attribute__preserves_existing_fmtp_on_rtpmap(self) -> None: + """Applying a=rtpmap preserves an already-set fmtp on the format.""" + media = MediaDescription( + media="audio", + port=0, + proto="RTP/AVP", + fmt=[ + RTPPayloadFormat( + payload_type=111, fmtp="minptime=10", sample_rate=48000 + ) + ], + ) + media.apply_attribute(Attribute(name="rtpmap", value="111 opus/48000/2")) + assert media.fmt[0].fmtp == "minptime=10" + + def test_apply_attribute__fmtp_non_numeric_pt__returns_false(self) -> None: + """apply_attribute returns False for a=fmtp with a non-numeric payload type.""" + media = MediaDescription( + media="image", + port=0, + proto="udptl", + fmt=[RTPPayloadFormat(payload_type="t38")], + ) + assert not media.apply_attribute( + Attribute(name="fmtp", value="t38 T38FaxVersion=0") + ) + + +class TestMediaDescriptionParseEdgeCases: + def test_parse__with_title(self) -> None: + """parse() populates title from an i= line.""" + media = MediaDescription.parse("audio 5004 RTP/AVP 0\r\ni=Voice Channel") + assert media.title == "Voice Channel" + + def test_parse__with_connection(self) -> None: + """parse() populates connection from a c= line.""" + media = MediaDescription.parse("audio 5004 RTP/AVP 0\r\nc=IN IP4 192.0.2.1") + assert media.connection is not None + assert "192.0.2.1" in str(media.connection) + + def test_parse__with_bandwidth(self) -> None: + """parse() appends bandwidth entries from b= lines.""" + media = MediaDescription.parse("audio 5004 RTP/AVP 0\r\nb=AS:64") + assert len(media.bandwidths) == 1 + + def test_parse__empty_and_no_equals_lines__skipped(self) -> None: + """parse() skips blank lines and lines without an = sign.""" + media = MediaDescription.parse("audio 5004 RTP/AVP 0\r\n\r\nNOEQUALS\r\n") + assert media.media == "audio" + assert media.port == 5004 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 0294297..ee07d7c 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -288,6 +288,114 @@ async def test_say__raises_when_not_connected(self) -> None: await say(ctx=ctx, target="sip:bob@example.com") +# --------------------------------------------------------------------------- +# send_fax tool +# --------------------------------------------------------------------------- + + +class TestSendFaxTool: + async def test_send_fax__dials_with_document_path(self, tmp_path) -> None: + """send_fax() reads document_path and dials with FaxCall session.""" + from voip.mcp import send_fax # noqa: PLC0415 + + aor = SipURI.parse("sip:alice@carrier.example;transport=TLS") + mock_sip = MagicMock(spec=SessionInitiationProtocol) + mock_sip.aor = aor + connection_pool.sip = mock_sip + + doc = tmp_path / "fax.txt" + doc.write_bytes(b"fax content") + ctx = make_mock_context() + target_uri = SipURI.parse("sip:bob@carrier.example") + + with patch("voip.mcp.parse_uri", return_value=target_uri): + with patch("voip.mcp.Dialog") as MockDialog: + mock_dialog = MagicMock(spec=Dialog) + MockDialog.return_value = mock_dialog + mock_dialog.dial = AsyncMock() + await send_fax( + ctx=ctx, + target="sip:bob@carrier.example", + document_path=str(doc), + ) + + _, kwargs = mock_dialog.dial.call_args + assert kwargs["session_class"].__name__ == "FaxCall" + assert kwargs["document"] == b"fax content" + + async def test_send_fax__dials_with_text(self) -> None: + """send_fax() encodes text as UTF-8 and dials with FaxCall session.""" + from voip.mcp import send_fax # noqa: PLC0415 + + aor = SipURI.parse("sip:alice@carrier.example") + mock_sip = MagicMock(spec=SessionInitiationProtocol) + mock_sip.aor = aor + connection_pool.sip = mock_sip + + ctx = make_mock_context() + target_uri = SipURI.parse("sip:bob@carrier.example") + + with patch("voip.mcp.parse_uri", return_value=target_uri): + with patch("voip.mcp.Dialog") as MockDialog: + mock_dialog = MagicMock(spec=Dialog) + MockDialog.return_value = mock_dialog + mock_dialog.dial = AsyncMock() + await send_fax(ctx=ctx, target="sip:bob@carrier.example", text="Hello") + + _, kwargs = mock_dialog.dial.call_args + assert kwargs["document"] == b"Hello" + + async def test_send_fax__document_path_takes_precedence(self, tmp_path) -> None: + """send_fax() prefers document_path over text when both are given.""" + from voip.mcp import send_fax # noqa: PLC0415 + + aor = SipURI.parse("sip:alice@carrier.example") + mock_sip = MagicMock(spec=SessionInitiationProtocol) + mock_sip.aor = aor + connection_pool.sip = mock_sip + + doc = tmp_path / "fax.bin" + doc.write_bytes(b"binary doc") + ctx = make_mock_context() + + with patch("voip.mcp.parse_uri", return_value=aor): + with patch("voip.mcp.Dialog") as MockDialog: + mock_dialog = MagicMock(spec=Dialog) + MockDialog.return_value = mock_dialog + mock_dialog.dial = AsyncMock() + await send_fax( + ctx=ctx, + target="sip:bob@carrier.example", + text="ignored", + document_path=str(doc), + ) + + _, kwargs = mock_dialog.dial.call_args + assert kwargs["document"] == b"binary doc" + + async def test_send_fax__raises_when_no_content(self) -> None: + """send_fax() raises ValueError when neither text nor document_path is given.""" + from voip.mcp import send_fax # noqa: PLC0415 + + aor = SipURI.parse("sip:alice@carrier.example") + mock_sip = MagicMock(spec=SessionInitiationProtocol) + mock_sip.aor = aor + connection_pool.sip = mock_sip + + with pytest.raises(ValueError, match="text or document_path"): + await send_fax(ctx=make_mock_context(), target="sip:bob@carrier.example") + + async def test_send_fax__raises_when_not_connected(self) -> None: + """send_fax() raises RuntimeError when connection_pool.sip is not set.""" + from voip.mcp import send_fax # noqa: PLC0415 + + if hasattr(connection_pool, "sip"): + del connection_pool.sip + + with pytest.raises(RuntimeError, match="run()"): + await send_fax(ctx=make_mock_context(), target="sip:bob@carrier.example") + + # --------------------------------------------------------------------------- # call tool # --------------------------------------------------------------------------- diff --git a/tests/test_rtp.py b/tests/test_rtp.py index 839e97d..9a82ed3 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -4,7 +4,7 @@ import dataclasses import ipaddress import struct -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from voip.rtp import RTP, RealtimeTransportProtocol, RTPPacket, RTPPayloadType, Session @@ -539,3 +539,43 @@ def test_negotiate_codec__raises_not_implemented(self): """negotiate_codec raises NotImplementedError in the base class.""" with pytest.raises(NotImplementedError): Session.negotiate_codec(MagicMock()) + + def test_hang_up__no_op_when_no_dialog(self): + """hang_up is a no-op when no dialog is attached to the session.""" + call = make_call(dialog=None) + asyncio.run(call.hang_up()) # must not raise + + async def test_hang_up__unregisters_call_and_sends_bye(self): + """hang_up deregisters the call from the RTP mux, then sends BYE.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_dialog = MagicMock() + mock_dialog.bye = AsyncMock() + call = make_call(rtp=mock_rtp, dialog=mock_dialog) + mock_rtp.calls = {("192.0.2.1", 5004): call} + await call.hang_up() + mock_rtp.unregister_call.assert_called_once_with(("192.0.2.1", 5004)) + mock_dialog.bye.assert_awaited_once() + + async def test_hang_up__sends_bye_when_not_registered(self): + """hang_up still sends BYE even if the call is not in rtp.calls.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_dialog = MagicMock() + mock_dialog.bye = AsyncMock() + call = make_call(rtp=mock_rtp, dialog=mock_dialog) + mock_rtp.calls = {} + await call.hang_up() + mock_rtp.unregister_call.assert_not_called() + mock_dialog.bye.assert_awaited_once() + + def test_sdp_formats__returns_pcmu(self): + """sdp_formats returns PCMU as the default payload format.""" + formats = Session.sdp_formats() + assert len(formats) == 1 + assert formats[0].payload_type == 0 + + def test_sdp_media_description__returns_audio_rtp_avp(self): + """sdp_media_description returns an m=audio RTP/AVP description.""" + description = Session.sdp_media_description(port=5004) + assert description.media == "audio" + assert description.proto == "RTP/AVP" + assert description.port == 5004 From 17a46cd394abf2a7e2ca4aac16d0efd7acac77db Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:43:33 +0000 Subject: [PATCH 4/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_fax.py | 10 +++------- voip/mcp.py | 3 +-- voip/sdp/types.py | 6 ++++-- voip/sip/transactions.py | 7 +++---- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/tests/test_fax.py b/tests/test_fax.py index 7d7c1c2..74c1971 100644 --- a/tests/test_fax.py +++ b/tests/test_fax.py @@ -1,10 +1,8 @@ """Tests for the T.38 FAX session (voip.fax).""" -import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest - from voip.fax import FaxCall, FaxSession from voip.rtp import RealtimeTransportProtocol from voip.sdp.types import MediaDescription, RTPPayloadFormat @@ -176,9 +174,7 @@ def test_send_document__sends_to_remote_address(self) -> None: session.send_document(b"fax content") mock_rtp.send.assert_called_once_with(b"fax content", remote_address) - def test_send_document__logs_warning_when_no_remote_address( - self, caplog - ) -> None: + def test_send_document__logs_warning_when_no_remote_address(self, caplog) -> None: """send_document logs a warning and does nothing when not registered.""" import logging # noqa: PLC0415 @@ -198,7 +194,7 @@ def test_send_document__logs_warning_when_no_remote_address( class TestFaxCall: async def test_transmit__sends_document_and_hangs_up(self) -> None: - """transmit sends the document, hangs up, and closes the SIP connection.""" + """Transmit sends the document, hangs up, and closes the SIP connection.""" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_dialog = MagicMock(spec=Dialog) mock_sip = MagicMock() @@ -222,7 +218,7 @@ async def test_transmit__sends_document_and_hangs_up(self) -> None: mock_sip.close.assert_called_once() async def test_transmit__skips_sip_close_when_no_sip(self) -> None: - """transmit does not raise when dialog.sip is None.""" + """Transmit does not raise when dialog.sip is None.""" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_dialog = MagicMock(spec=Dialog) mock_dialog.sip = None diff --git a/voip/mcp.py b/voip/mcp.py index 068c3a2..06d46e2 100644 --- a/voip/mcp.py +++ b/voip/mcp.py @@ -5,11 +5,10 @@ import asyncio import dataclasses +import pathlib import threading import typing -import pathlib - from fastmcp import Context, FastMCP from mcp.types import SamplingMessage, TextContent diff --git a/voip/sdp/types.py b/voip/sdp/types.py index 572115d..17b7267 100644 --- a/voip/sdp/types.py +++ b/voip/sdp/types.py @@ -357,7 +357,7 @@ def get_format(self, pt: int | str) -> RTPPayloadFormat | None: """Return the `RTPPayloadFormat` for payload type *pt*, or ``None``.""" try: target: int | str = int(pt) - except (TypeError, ValueError): + except TypeError, ValueError: target = str(pt) return next((f for f in self.fmt if f.payload_type == target), None) @@ -418,7 +418,9 @@ def parse(cls, data: bytes | str) -> MediaDescription: first = lines[0].rstrip("\r").removeprefix("m=") media_type, port_str, proto, *fmts = first.split() fmt = [ - RTPPayloadFormat.from_pt(int(pt)) if pt.isdigit() else RTPPayloadFormat(payload_type=pt) + RTPPayloadFormat.from_pt(int(pt)) + if pt.isdigit() + else RTPPayloadFormat(payload_type=pt) for pt in fmts ] obj = cls(media=media_type, port=int(port_str), proto=proto, fmt=fmt) diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index dbec574..9d8a71e 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -569,7 +569,8 @@ def call_received(self) -> None: session_id = str(secrets.randbelow(2**32) + 1) rtp_public = self.sip.public_address sdp_media_attributes = list( - negotiated_media.attributes if negotiated_media.attributes + negotiated_media.attributes + if negotiated_media.attributes else [Attribute(name="sendrecv")] ) if srtp_session is not None: @@ -680,9 +681,7 @@ async def send( ), connection_address=str(rtp_public[0]), ), - media=[ - session_class.sdp_media_description(rtp_public[1]) - ], + media=[session_class.sdp_media_description(rtp_public[1])], ) tx.request = Request( method=SIPMethod.INVITE, From 2f8c3109eedc5752e382c3c16545c8ba2e841f97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:05:03 +0000 Subject: [PATCH 5/8] =?UTF-8?q?Address=20PR=20review:=20rename=20FaxCall?= =?UTF-8?q?=E2=86=92OutboundFaxSession,=20add=20InboundFaxSession,=20fix?= =?UTF-8?q?=20sdp/types=20early=20return,=20simplify=20frame=5Fsize,=20dro?= =?UTF-8?q?p=20separator=20comments,=20thousand-sep=20ints,=20update=20doc?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rfc_status.md | 1 + docs/sessions.md | 8 +++- tests/sdp/test_messages.py | 10 ++--- tests/test_fax.py | 80 ++++++++++++++++---------------------- tests/test_mcp.py | 11 ++---- voip/__main__.py | 4 +- voip/fax.py | 28 +++++++++---- voip/mcp.py | 4 +- voip/sdp/types.py | 31 ++++++--------- 9 files changed, 86 insertions(+), 91 deletions(-) diff --git a/docs/rfc_status.md b/docs/rfc_status.md index d2b1dea..b39effa 100644 --- a/docs/rfc_status.md +++ b/docs/rfc_status.md @@ -24,6 +24,7 @@ | [RFC 7983](https://datatracker.ietf.org/doc/html/rfc7983) | Multiplexing Scheme Updates for SRTP Extension for DTLS | Complete | First-byte demultiplexing of STUN vs. RTP/SRTP | | [RFC 7587](https://datatracker.ietf.org/doc/html/rfc7587) | RTP Payload Format for the Opus Speech and Audio Codec | Complete | Dynamic payload type 111 | | [RFC 3533](https://datatracker.ietf.org/doc/html/rfc3533) | The Ogg Encapsulation Format Version 0 | Partial | Minimal Ogg page writer for Opus audio export | +| [RFC 3362](https://datatracker.ietf.org/doc/html/rfc3362) | Real-time Facsimile (T.38) — image/t38 MIME Sub-type Registration | Complete | T.38 over SIP/UDPTL via `FaxSession`, `OutboundFaxSession`, and `InboundFaxSession` | | [RFC 4733](https://datatracker.ietf.org/doc/html/rfc4733) | RTP Payload for DTMF Digits, Telephony Tones, and Telephony Signals | Planned | In-band DTMF over RTP | ## IVR and Application Services diff --git a/docs/sessions.md b/docs/sessions.md index 240b17d..b1b428e 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -26,6 +26,12 @@ Sessions can be audio, video, and more. This library provides audio sessions via ## FAX (T.38) +Implements [RFC 3362] for T.38 fax over SIP/UDPTL. + ::: voip.fax.FaxSession -::: voip.fax.FaxCall +::: voip.fax.OutboundFaxSession + +::: voip.fax.InboundFaxSession + +[RFC 3362]: https://datatracker.ietf.org/doc/html/rfc3362 diff --git a/tests/sdp/test_messages.py b/tests/sdp/test_messages.py index 6c2d434..81f548c 100644 --- a/tests/sdp/test_messages.py +++ b/tests/sdp/test_messages.py @@ -777,19 +777,19 @@ def test_frame_size__static_pcmu(self) -> None: def test_frame_size__dynamic_with_sample_rate(self) -> None: """frame_size is derived from sample_rate for dynamic payload types.""" f = RTPPayloadFormat( - payload_type=111, encoding_name="opus", sample_rate=48000, channels=2 + payload_type=111, encoding_name="opus", sample_rate=48_000, channels=2 ) - assert f.frame_size == 48000 * 20 // 1000 + assert f.frame_size == 48_000 * 20 // 1000 def test_frame_size__dynamic_without_sample_rate__uses_8000_default(self) -> None: """frame_size falls back to 8000 Hz when sample_rate is None.""" f = RTPPayloadFormat(payload_type=99) - assert f.frame_size == 8000 * 20 // 1000 + assert f.frame_size == 8_000 * 20 // 1000 def test_frame_size__str_payload_type__uses_sample_rate_fallback(self) -> None: """String payload types bypass StaticPayloadType and use sample_rate fallback.""" f = RTPPayloadFormat(payload_type="t38") - assert f.frame_size == 8000 * 20 // 1000 + assert f.frame_size == 8_000 * 20 // 1000 class TestMediaDescriptionGetFormatNonInt: @@ -823,7 +823,7 @@ def test_apply_attribute__preserves_existing_fmtp_on_rtpmap(self) -> None: proto="RTP/AVP", fmt=[ RTPPayloadFormat( - payload_type=111, fmtp="minptime=10", sample_rate=48000 + payload_type=111, fmtp="minptime=10", sample_rate=48_000 ) ], ) diff --git a/tests/test_fax.py b/tests/test_fax.py index 74c1971..152f425 100644 --- a/tests/test_fax.py +++ b/tests/test_fax.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from voip.fax import FaxCall, FaxSession +from voip.fax import FaxSession, InboundFaxSession, OutboundFaxSession from voip.rtp import RealtimeTransportProtocol from voip.sdp.types import MediaDescription, RTPPayloadFormat from voip.sip.dialog import Dialog @@ -23,11 +23,6 @@ def make_fax_session(**kwargs) -> FaxSession: return FaxSession(**defaults) -# --------------------------------------------------------------------------- -# FaxSession class attributes -# --------------------------------------------------------------------------- - - class TestFaxSessionAttributes: def test_media_type__is_image(self) -> None: """media_type class variable is 'image' for T.38.""" @@ -39,12 +34,7 @@ def test_t38_version__is_zero(self) -> None: def test_t38_max_bit_rate__is_14400(self) -> None: """T38_MAX_BIT_RATE defaults to 14400 bps.""" - assert FaxSession.T38_MAX_BIT_RATE == 14400 - - -# --------------------------------------------------------------------------- -# FaxSession.sdp_formats -# --------------------------------------------------------------------------- + assert FaxSession.T38_MAX_BIT_RATE == 14_400 class TestSdpFormats: @@ -55,11 +45,6 @@ def test_sdp_formats__returns_t38_format(self) -> None: assert formats[0].payload_type == "t38" -# --------------------------------------------------------------------------- -# FaxSession.sdp_media_description -# --------------------------------------------------------------------------- - - class TestSdpMediaDescription: def test_sdp_media_description__media_is_image(self) -> None: """sdp_media_description produces an m=image section.""" @@ -93,11 +78,6 @@ def test_sdp_media_description__default_port_is_zero(self) -> None: assert FaxSession.sdp_media_description().port == 0 -# --------------------------------------------------------------------------- -# FaxSession.negotiate_codec -# --------------------------------------------------------------------------- - - class TestNegotiateCodec: def test_negotiate_codec__accepts_t38_offer(self) -> None: """negotiate_codec returns T.38 MediaDescription for a valid T.38 offer.""" @@ -133,11 +113,6 @@ def test_negotiate_codec__uses_remote_port(self) -> None: assert FaxSession.negotiate_codec(offer).port == 7070 -# --------------------------------------------------------------------------- -# FaxSession.data_received -# --------------------------------------------------------------------------- - - class TestDataReceived: def test_data_received__delegates_to_document_received(self) -> None: """data_received forwards the raw data to document_received.""" @@ -148,22 +123,12 @@ def test_data_received__delegates_to_document_received(self) -> None: assert received == [b"t38 packet"] -# --------------------------------------------------------------------------- -# FaxSession.document_received -# --------------------------------------------------------------------------- - - class TestDocumentReceived: def test_document_received__is_noop(self) -> None: """document_received is a no-op in the base class.""" make_fax_session().document_received(b"data") # must not raise -# --------------------------------------------------------------------------- -# FaxSession.send_document -# --------------------------------------------------------------------------- - - class TestSendDocument: def test_send_document__sends_to_remote_address(self) -> None: """send_document transmits data to the registered remote address.""" @@ -187,12 +152,7 @@ def test_send_document__logs_warning_when_no_remote_address(self, caplog) -> Non assert any("No remote address" in r.message for r in caplog.records) -# --------------------------------------------------------------------------- -# FaxCall -# --------------------------------------------------------------------------- - - -class TestFaxCall: +class TestOutboundFaxSession: async def test_transmit__sends_document_and_hangs_up(self) -> None: """Transmit sends the document, hangs up, and closes the SIP connection.""" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) @@ -201,7 +161,7 @@ async def test_transmit__sends_document_and_hangs_up(self) -> None: mock_dialog.sip = mock_sip remote_address = ("127.0.0.1", 5004) - session = FaxCall( + session = OutboundFaxSession( rtp=mock_rtp, dialog=mock_dialog, media=FaxSession.sdp_media_description(), @@ -224,7 +184,7 @@ async def test_transmit__skips_sip_close_when_no_sip(self) -> None: mock_dialog.sip = None mock_rtp.calls = {} - session = FaxCall( + session = OutboundFaxSession( rtp=mock_rtp, dialog=mock_dialog, media=FaxSession.sdp_media_description(), @@ -242,7 +202,7 @@ def test_post_init__creates_transmit_task(self) -> None: mock_dialog.sip = None with patch("asyncio.create_task") as mock_create_task: - FaxCall( + OutboundFaxSession( rtp=mock_rtp, dialog=mock_dialog, media=FaxSession.sdp_media_description(), @@ -251,3 +211,31 @@ def test_post_init__creates_transmit_task(self) -> None: ) mock_create_task.assert_called_once() + + +class TestInboundFaxSession: + def test_document_received__accumulates_data(self) -> None: + """document_received appends each packet to the document buffer.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_dialog = MagicMock(spec=Dialog) + session = InboundFaxSession( + rtp=mock_rtp, + dialog=mock_dialog, + media=FaxSession.sdp_media_description(), + caller=CallerID(""), + ) + session.document_received(b"page1") + session.document_received(b"page2") + assert session.document == b"page1page2" + + def test_document__starts_empty(self) -> None: + """Document buffer is empty bytes before any data is received.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_dialog = MagicMock(spec=Dialog) + session = InboundFaxSession( + rtp=mock_rtp, + dialog=mock_dialog, + media=FaxSession.sdp_media_description(), + caller=CallerID(""), + ) + assert session.document == b"" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index ee07d7c..38558a4 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -288,14 +288,9 @@ async def test_say__raises_when_not_connected(self) -> None: await say(ctx=ctx, target="sip:bob@example.com") -# --------------------------------------------------------------------------- -# send_fax tool -# --------------------------------------------------------------------------- - - class TestSendFaxTool: async def test_send_fax__dials_with_document_path(self, tmp_path) -> None: - """send_fax() reads document_path and dials with FaxCall session.""" + """send_fax() reads document_path and dials with OutboundFaxSession.""" from voip.mcp import send_fax # noqa: PLC0415 aor = SipURI.parse("sip:alice@carrier.example;transport=TLS") @@ -320,11 +315,11 @@ async def test_send_fax__dials_with_document_path(self, tmp_path) -> None: ) _, kwargs = mock_dialog.dial.call_args - assert kwargs["session_class"].__name__ == "FaxCall" + assert kwargs["session_class"].__name__ == "OutboundFaxSession" assert kwargs["document"] == b"fax content" async def test_send_fax__dials_with_text(self) -> None: - """send_fax() encodes text as UTF-8 and dials with FaxCall session.""" + """send_fax() encodes text as UTF-8 and dials with OutboundFaxSession.""" from voip.mcp import send_fax # noqa: PLC0415 aor = SipURI.parse("sip:alice@carrier.example") diff --git a/voip/__main__.py b/voip/__main__.py index fdb7c6c..5b1c0b5 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -9,7 +9,7 @@ import time from voip.ai import SayCall -from voip.fax import FaxCall +from voip.fax import OutboundFaxSession from voip.rtp import RealtimeTransportProtocol, Session from voip.sip import dialog, messages from voip.sip.protocol import SessionInitiationProtocol @@ -583,7 +583,7 @@ async def run(): aor=aor, rtp_protocol=rtp_protocol, target_uri=parse_uri(target, aor), - session_class=FaxCall, + session_class=OutboundFaxSession, session_kwargs={"document": open(document, "rb").read()}, # noqa: WPS515 ), aor.maddr, diff --git a/voip/fax.py b/voip/fax.py index dc70078..8e89f37 100644 --- a/voip/fax.py +++ b/voip/fax.py @@ -15,7 +15,7 @@ from voip.sdp.types import Attribute, MediaDescription, RTPPayloadFormat from voip.types import NetworkAddress -__all__ = ["FaxCall", "FaxSession"] +__all__ = ["FaxSession", "OutboundFaxSession", "InboundFaxSession"] logger = logging.getLogger(__name__) @@ -43,12 +43,6 @@ class FaxSession(Session): T38_MAX_BIT_RATE: ClassVar[int] = 14400 def data_received(self, data: bytes, address: NetworkAddress) -> None: - """Handle an incoming T.38 UDPTL packet. - - Args: - data: Raw UDPTL packet bytes. - address: Source ``(host, port)`` of the datagram. - """ self.document_received(data) def document_received(self, data: bytes) -> None: @@ -123,7 +117,7 @@ def sdp_media_description(cls, port: int = 0) -> MediaDescription: @dataclasses.dataclass(kw_only=True, slots=True) -class FaxCall(FaxSession): +class OutboundFaxSession(FaxSession): """Dial a number, send a FAX document, and hang up. Attributes: @@ -141,3 +135,21 @@ async def transmit(self) -> None: await self.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 InboundFaxSession(FaxSession): + """Collect incoming T.38 UDPTL packets into a single document buffer. + + Each UDPTL packet appended to `document` via + [document_received][voip.fax.InboundFaxSession.document_received]. + Override that method to process packets individually instead. + + Attributes: + document: Accumulated T.38 UDPTL data received so far. + """ + + document: bytes = dataclasses.field(default=b"", init=False) + + def document_received(self, data: bytes) -> None: + self.document += data diff --git a/voip/mcp.py b/voip/mcp.py index 06d46e2..75f2083 100644 --- a/voip/mcp.py +++ b/voip/mcp.py @@ -15,7 +15,7 @@ import voip from voip import ai from voip.ai import SayCall -from voip.fax import FaxCall +from voip.fax import OutboundFaxSession from voip.sip import Dialog from voip.sip.protocol import SessionInitiationProtocol from voip.sip.types import SipURI, parse_uri @@ -132,7 +132,7 @@ async def send_fax( ) target_uri = parse_uri(target, connection_pool.sip.aor) dialog = Dialog(sip=connection_pool.sip) - await dialog.dial(target_uri, session_class=FaxCall, document=document) + await dialog.dial(target_uri, session_class=OutboundFaxSession, document=document) @mcp.tool diff --git a/voip/sdp/types.py b/voip/sdp/types.py index 17b7267..0c97755 100644 --- a/voip/sdp/types.py +++ b/voip/sdp/types.py @@ -279,16 +279,15 @@ class RTPPayloadFormat(ByteSerializableObject): sample_rate: int | None = None def __post_init__(self): - if not isinstance(self.payload_type, int): - return - try: - default = StaticPayloadType.from_pt(self.payload_type) - except ValueError: - pass - else: - self.sample_rate = self.sample_rate or default.sample_rate - self.encoding_name = self.encoding_name or default.encoding_name - self.channels = self.channels or default.channels + if isinstance(self.payload_type, int): + try: + default = StaticPayloadType.from_pt(self.payload_type) + except ValueError: + pass + else: + self.sample_rate = self.sample_rate or default.sample_rate + self.encoding_name = self.encoding_name or default.encoding_name + self.channels = self.channels or default.channels def __bytes__(self) -> bytes: base = f"{self.payload_type} {self.encoding_name}/{self.sample_rate}" @@ -321,18 +320,12 @@ def from_pt(cls, pt: int) -> RTPPayloadFormat: def frame_size(self) -> int: """Samples per standard 20 ms RTP frame. - For static payload types the value comes from `StaticPayloadType`. + For static payload types the value comes from `sample_rate` (populated + by `__post_init__` from `StaticPayloadType`). For dynamic payload types (e.g. Opus, PT ≥ 96) it is derived from `sample_rate` assuming a 20 ms packetisation interval. """ - if isinstance(self.payload_type, int): - try: - spec = StaticPayloadType.from_pt(self.payload_type) - if spec.frame_size: - return spec.frame_size - except ValueError: - pass - return (self.sample_rate or 8000) * 20 // 1000 + return (self.sample_rate or 8_000) * 20 // 1000 @dataclasses.dataclass(slots=True) From 0be499251eaa76230b8e6635d72a213f447e2293 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:48:02 +0000 Subject: [PATCH 6/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/rfc_status.md | 26 +++++++++++++------------- docs/sessions.md | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/rfc_status.md b/docs/rfc_status.md index b39effa..9d019c5 100644 --- a/docs/rfc_status.md +++ b/docs/rfc_status.md @@ -13,19 +13,19 @@ ## Media Transport -| RFC | Title | Status | Notes | -| --------------------------------------------------------- | ------------------------------------------------------------------- | -------- | -------------------------------------------------------------- | -| [RFC 3550](https://datatracker.ietf.org/doc/html/rfc3550) | RTP: A Transport Protocol for Real-Time Applications | Complete | Full RTP packet parsing and per-call multiplexing | -| [RFC 3551](https://datatracker.ietf.org/doc/html/rfc3551) | RTP Profile for Audio and Video Conferences | Partial | PCMU (0), PCMA (8), G.722 (9), and Opus (111) payload types | -| [RFC 3711](https://datatracker.ietf.org/doc/html/rfc3711) | Secure Real-time Transport Protocol (SRTP) | Complete | AES-CM-128-HMAC-SHA1-80 encryption and authentication | -| [RFC 4566](https://datatracker.ietf.org/doc/html/rfc4566) | SDP: Session Description Protocol | Partial | Offer/answer model for audio calls; connection and media lines | -| [RFC 4568](https://datatracker.ietf.org/doc/html/rfc4568) | SDP Security Descriptions for Media Streams (SDES) | Complete | Inline SRTP key exchange via `a=crypto:` | -| [RFC 5389](https://datatracker.ietf.org/doc/html/rfc5389) | STUN: Session Traversal Utilities for NAT | Complete | Binding Request/Response with XOR-MAPPED-ADDRESS | -| [RFC 7983](https://datatracker.ietf.org/doc/html/rfc7983) | Multiplexing Scheme Updates for SRTP Extension for DTLS | Complete | First-byte demultiplexing of STUN vs. RTP/SRTP | -| [RFC 7587](https://datatracker.ietf.org/doc/html/rfc7587) | RTP Payload Format for the Opus Speech and Audio Codec | Complete | Dynamic payload type 111 | -| [RFC 3533](https://datatracker.ietf.org/doc/html/rfc3533) | The Ogg Encapsulation Format Version 0 | Partial | Minimal Ogg page writer for Opus audio export | -| [RFC 3362](https://datatracker.ietf.org/doc/html/rfc3362) | Real-time Facsimile (T.38) — image/t38 MIME Sub-type Registration | Complete | T.38 over SIP/UDPTL via `FaxSession`, `OutboundFaxSession`, and `InboundFaxSession` | -| [RFC 4733](https://datatracker.ietf.org/doc/html/rfc4733) | RTP Payload for DTMF Digits, Telephony Tones, and Telephony Signals | Planned | In-band DTMF over RTP | +| RFC | Title | Status | Notes | +| --------------------------------------------------------- | ------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------- | +| [RFC 3550](https://datatracker.ietf.org/doc/html/rfc3550) | RTP: A Transport Protocol for Real-Time Applications | Complete | Full RTP packet parsing and per-call multiplexing | +| [RFC 3551](https://datatracker.ietf.org/doc/html/rfc3551) | RTP Profile for Audio and Video Conferences | Partial | PCMU (0), PCMA (8), G.722 (9), and Opus (111) payload types | +| [RFC 3711](https://datatracker.ietf.org/doc/html/rfc3711) | Secure Real-time Transport Protocol (SRTP) | Complete | AES-CM-128-HMAC-SHA1-80 encryption and authentication | +| [RFC 4566](https://datatracker.ietf.org/doc/html/rfc4566) | SDP: Session Description Protocol | Partial | Offer/answer model for audio calls; connection and media lines | +| [RFC 4568](https://datatracker.ietf.org/doc/html/rfc4568) | SDP Security Descriptions for Media Streams (SDES) | Complete | Inline SRTP key exchange via `a=crypto:` | +| [RFC 5389](https://datatracker.ietf.org/doc/html/rfc5389) | STUN: Session Traversal Utilities for NAT | Complete | Binding Request/Response with XOR-MAPPED-ADDRESS | +| [RFC 7983](https://datatracker.ietf.org/doc/html/rfc7983) | Multiplexing Scheme Updates for SRTP Extension for DTLS | Complete | First-byte demultiplexing of STUN vs. RTP/SRTP | +| [RFC 7587](https://datatracker.ietf.org/doc/html/rfc7587) | RTP Payload Format for the Opus Speech and Audio Codec | Complete | Dynamic payload type 111 | +| [RFC 3533](https://datatracker.ietf.org/doc/html/rfc3533) | The Ogg Encapsulation Format Version 0 | Partial | Minimal Ogg page writer for Opus audio export | +| [RFC 3362](https://datatracker.ietf.org/doc/html/rfc3362) | Real-time Facsimile (T.38) — image/t38 MIME Sub-type Registration | Complete | T.38 over SIP/UDPTL via `FaxSession`, `OutboundFaxSession`, and `InboundFaxSession` | +| [RFC 4733](https://datatracker.ietf.org/doc/html/rfc4733) | RTP Payload for DTMF Digits, Telephony Tones, and Telephony Signals | Planned | In-band DTMF over RTP | ## IVR and Application Services diff --git a/docs/sessions.md b/docs/sessions.md index b1b428e..269691b 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -34,4 +34,4 @@ Implements [RFC 3362] for T.38 fax over SIP/UDPTL. ::: voip.fax.InboundFaxSession -[RFC 3362]: https://datatracker.ietf.org/doc/html/rfc3362 +[rfc 3362]: https://datatracker.ietf.org/doc/html/rfc3362 From dd421ed6e2c2b03970e1d25ae3283f2c2e4a4a6f Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 10 Jun 2026 17:12:50 +0200 Subject: [PATCH 7/8] Amendments --- docs/rfc_status.md | 26 ++++++++++++------------ tests/test_mcp.py | 39 ------------------------------------ voip/ai.py | 2 +- voip/audio.py | 6 +++--- voip/codecs/__init__.py | 6 +++--- voip/codecs/av.py | 2 +- voip/codecs/g722.py | 2 +- voip/codecs/opus.py | 2 +- voip/fax.py | 43 ++++++++++------------------------------ voip/mcp.py | 2 +- voip/rtp.py | 26 ++++++++++++------------ voip/sdp/messages.py | 4 ++-- voip/sdp/types.py | 12 +++++------ voip/sip/dialog.py | 2 +- voip/sip/exceptions.py | 2 +- voip/sip/protocol.py | 28 +++++++++++++------------- voip/sip/transactions.py | 24 +++++++++++----------- voip/sip/types.py | 6 +++--- voip/stun.py | 6 +++--- 19 files changed, 89 insertions(+), 151 deletions(-) diff --git a/docs/rfc_status.md b/docs/rfc_status.md index 9d019c5..cc6542d 100644 --- a/docs/rfc_status.md +++ b/docs/rfc_status.md @@ -13,19 +13,19 @@ ## Media Transport -| RFC | Title | Status | Notes | -| --------------------------------------------------------- | ------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------- | -| [RFC 3550](https://datatracker.ietf.org/doc/html/rfc3550) | RTP: A Transport Protocol for Real-Time Applications | Complete | Full RTP packet parsing and per-call multiplexing | -| [RFC 3551](https://datatracker.ietf.org/doc/html/rfc3551) | RTP Profile for Audio and Video Conferences | Partial | PCMU (0), PCMA (8), G.722 (9), and Opus (111) payload types | -| [RFC 3711](https://datatracker.ietf.org/doc/html/rfc3711) | Secure Real-time Transport Protocol (SRTP) | Complete | AES-CM-128-HMAC-SHA1-80 encryption and authentication | -| [RFC 4566](https://datatracker.ietf.org/doc/html/rfc4566) | SDP: Session Description Protocol | Partial | Offer/answer model for audio calls; connection and media lines | -| [RFC 4568](https://datatracker.ietf.org/doc/html/rfc4568) | SDP Security Descriptions for Media Streams (SDES) | Complete | Inline SRTP key exchange via `a=crypto:` | -| [RFC 5389](https://datatracker.ietf.org/doc/html/rfc5389) | STUN: Session Traversal Utilities for NAT | Complete | Binding Request/Response with XOR-MAPPED-ADDRESS | -| [RFC 7983](https://datatracker.ietf.org/doc/html/rfc7983) | Multiplexing Scheme Updates for SRTP Extension for DTLS | Complete | First-byte demultiplexing of STUN vs. RTP/SRTP | -| [RFC 7587](https://datatracker.ietf.org/doc/html/rfc7587) | RTP Payload Format for the Opus Speech and Audio Codec | Complete | Dynamic payload type 111 | -| [RFC 3533](https://datatracker.ietf.org/doc/html/rfc3533) | The Ogg Encapsulation Format Version 0 | Partial | Minimal Ogg page writer for Opus audio export | -| [RFC 3362](https://datatracker.ietf.org/doc/html/rfc3362) | Real-time Facsimile (T.38) — image/t38 MIME Sub-type Registration | Complete | T.38 over SIP/UDPTL via `FaxSession`, `OutboundFaxSession`, and `InboundFaxSession` | -| [RFC 4733](https://datatracker.ietf.org/doc/html/rfc4733) | RTP Payload for DTMF Digits, Telephony Tones, and Telephony Signals | Planned | In-band DTMF over RTP | +| RFC | Title | Status | Notes | +| --------------------------------------------------------- | ------------------------------------------------------------------- | -------- | -------------------------------------------------------------- | +| [RFC 3550](https://datatracker.ietf.org/doc/html/rfc3550) | RTP: A Transport Protocol for Real-Time Applications | Complete | Full RTP packet parsing and per-call multiplexing | +| [RFC 3551](https://datatracker.ietf.org/doc/html/rfc3551) | RTP Profile for Audio and Video Conferences | Partial | PCMU (0), PCMA (8), G.722 (9), and Opus (111) payload types | +| [RFC 3711](https://datatracker.ietf.org/doc/html/rfc3711) | Secure Real-time Transport Protocol (SRTP) | Complete | AES-CM-128-HMAC-SHA1-80 encryption and authentication | +| [RFC 4566](https://datatracker.ietf.org/doc/html/rfc4566) | SDP: Session Description Protocol | Partial | Offer/answer model for audio calls; connection and media lines | +| [RFC 4568](https://datatracker.ietf.org/doc/html/rfc4568) | SDP Security Descriptions for Media Streams (SDES) | Complete | Inline SRTP key exchange via `a=crypto:` | +| [RFC 5389](https://datatracker.ietf.org/doc/html/rfc5389) | STUN: Session Traversal Utilities for NAT | Complete | Binding Request/Response with XOR-MAPPED-ADDRESS | +| [RFC 7983](https://datatracker.ietf.org/doc/html/rfc7983) | Multiplexing Scheme Updates for SRTP Extension for DTLS | Complete | First-byte demultiplexing of STUN vs. RTP/SRTP | +| [RFC 7587](https://datatracker.ietf.org/doc/html/rfc7587) | RTP Payload Format for the Opus Speech and Audio Codec | Complete | Dynamic payload type 111 | +| [RFC 3533](https://datatracker.ietf.org/doc/html/rfc3533) | The Ogg Encapsulation Format Version 0 | Partial | Minimal Ogg page writer for Opus audio export | +| [RFC 3362](https://datatracker.ietf.org/doc/html/rfc3362) | Real-time Facsimile (T.38) — image/t38 MIME Sub-type Registration | Complete | T.38 over SIP/UDPTL | +| [RFC 4733](https://datatracker.ietf.org/doc/html/rfc4733) | RTP Payload for DTMF Digits, Telephony Tones, and Telephony Signals | Planned | In-band DTMF over RTP | ## IVR and Application Services diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 38558a4..2d4989e 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -19,10 +19,6 @@ from voip.sip.types import CallerID, SipURI # noqa: E402 from voip.types import NetworkAddress # noqa: E402 -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - def make_media() -> MediaDescription: """Return a minimal MediaDescription for testing.""" @@ -76,11 +72,6 @@ def make_agent_call( return agent -# --------------------------------------------------------------------------- -# MCPAgentCall.transcript -# --------------------------------------------------------------------------- - - class TestTranscript: def test_transcript__empty(self) -> None: """Return empty string when only a system message exists.""" @@ -108,11 +99,6 @@ def test_transcript__skips_system_messages(self) -> None: assert "Caller: test" in agent.transcript -# --------------------------------------------------------------------------- -# MCPAgentCall.transcription_received -# --------------------------------------------------------------------------- - - class TestTranscriptionReceived: async def test_transcription_received__appends_user_message(self) -> None: """Incoming transcription is appended as a user message.""" @@ -152,11 +138,6 @@ async def test_transcription_received__skips_cancel_when_done(self) -> None: old_task.cancel.assert_not_called() -# --------------------------------------------------------------------------- -# MCPAgentCall.respond -# --------------------------------------------------------------------------- - - class TestRespond: async def test_respond__speaks_reply(self) -> None: """respond() speaks the LLM reply and appends it to _messages.""" @@ -229,11 +210,6 @@ async def test_respond__none_text_is_silent(self) -> None: mock_send.assert_not_awaited() -# --------------------------------------------------------------------------- -# say tool -# --------------------------------------------------------------------------- - - class TestSayTool: async def test_say__dials_with_parsed_uri(self) -> None: """say() resolves the target via parse_uri relative to the AOR.""" @@ -391,11 +367,6 @@ async def test_send_fax__raises_when_not_connected(self) -> None: await send_fax(ctx=make_mock_context(), target="sip:bob@carrier.example") -# --------------------------------------------------------------------------- -# call tool -# --------------------------------------------------------------------------- - - class TestCallTool: async def test_call__raises_when_not_connected(self) -> None: """call() raises RuntimeError when connection_pool.sip is not set.""" @@ -484,11 +455,6 @@ async def test_call__default_empty_initial_prompt(self) -> None: assert kwargs["salutation"] == "" -# --------------------------------------------------------------------------- -# run() -# --------------------------------------------------------------------------- - - class TestRun: async def test_run__sets_connection_pool_sip(self) -> None: """run() stores the SIP protocol in connection_pool.sip.""" @@ -561,11 +527,6 @@ async def test_run__passes_stun_server(self) -> None: assert kwargs["stun_server"] is stun -# --------------------------------------------------------------------------- -# SessionInitiationProtocol.registered_event -# --------------------------------------------------------------------------- - - class TestRegisteredEvent: def test_registered_event__set_by_on_registered(self) -> None: """on_registered() sets registered_event so run() can unblock.""" diff --git a/voip/ai.py b/voip/ai.py index b0857de..dd900b7 100644 --- a/voip/ai.py +++ b/voip/ai.py @@ -4,7 +4,7 @@ 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]``. +Requires the `ai` extra: `pip install voip[ai]`. """ import asyncio diff --git a/voip/audio.py b/voip/audio.py index 8c39d1e..13861a0 100644 --- a/voip/audio.py +++ b/voip/audio.py @@ -4,9 +4,9 @@ packets, negotiates codecs, and decodes/encodes audio using the codec implementations in [voip.codecs][voip.codecs]. -Requires the ``audio`` extra: ``pip install voip[audio]``. +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 @@ -209,7 +209,7 @@ 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``). + 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. diff --git a/voip/codecs/__init__.py b/voip/codecs/__init__.py index 1665a08..46ae7fb 100644 --- a/voip/codecs/__init__.py +++ b/voip/codecs/__init__.py @@ -5,13 +5,13 @@ - [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)* +- [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 name (case-insensitive). -When the ``pyav`` extra is not installed only PCMA and PCMU are registered. +When the `pyav` extra is not installed only PCMA and PCMU are registered. """ from voip.codecs.base import RTPCodec diff --git a/voip/codecs/av.py b/voip/codecs/av.py index b0d75c9..d50c9c8 100644 --- a/voip/codecs/av.py +++ b/voip/codecs/av.py @@ -6,7 +6,7 @@ [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]``. +Requires the `pyav` extra: `pip install voip[pyav]`. Concrete subclasses: [Opus][voip.codecs.Opus], [G722][voip.codecs.G722]. diff --git a/voip/codecs/g722.py b/voip/codecs/g722.py index f75f547..1eed7a6 100644 --- a/voip/codecs/g722.py +++ b/voip/codecs/g722.py @@ -8,7 +8,7 @@ stateful decoding that preserves the ADPCM predictor state across consecutive RTP packets. -Requires the ``hd-audio`` extra: ``pip install voip[hd-audio]``. +Requires the `hd-audio` extra: `pip install voip[hd-audio]`. """ import dataclasses diff --git a/voip/codecs/opus.py b/voip/codecs/opus.py index 1f49657..0680d2a 100644 --- a/voip/codecs/opus.py +++ b/voip/codecs/opus.py @@ -4,7 +4,7 @@ minimal [Ogg][] container before passing them to PyAV for decoding, and encodes float32 PCM via `libopus`. -Requires the ``pyav`` extra: ``pip install voip[pyav]``. +Requires the `pyav` extra: `pip install voip[pyav]`. [Ogg]: https://wiki.xiph.org/Ogg """ diff --git a/voip/fax.py b/voip/fax.py index 8e89f37..116a742 100644 --- a/voip/fax.py +++ b/voip/fax.py @@ -24,13 +24,6 @@ class FaxSession(Session): """T.38 FAX over SIP/UDPTL session [RFC 3362]. - Handles SIP signaling and UDPTL media transport for sending and receiving - fax documents. Override - [document_received][voip.fax.FaxSession.document_received] to process - incoming fax data, and use - [send_document][voip.fax.FaxSession.send_document] to transmit outbound - data. - Attributes: T38_VERSION: T.38 protocol version advertised in SDP. T38_MAX_BIT_RATE: Maximum fax bit rate in bits per second. @@ -60,49 +53,37 @@ def send_document(self, data: bytes) -> None: Args: data: Raw document data to send. """ - remote_address = next( + if remote_address := next( (address for address, call in self.rtp.calls.items() if call is self), None, - ) - if remote_address is None: + ): + self.rtp.send(data, remote_address) + else: logger.warning("No remote address for FAX call; dropping document data") - return - self.rtp.send(data, remote_address) @classmethod def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: - """Negotiate T.38 from a remote SDP ``m=image`` offer. + """Negotiate T.38 from a remote SDP `m=image` offer. Args: - remote_media: The SDP ``m=image`` section from the remote INVITE. + remote_media: The SDP `m=image` section from the remote INVITE. Returns: - A T.38 `MediaDescription` for the response SDP. + A T.38 media description for the response SDP. Raises: NotImplementedError: When the remote offer does not include T.38. """ - if not any(str(fmt.payload_type).lower() == "t38" for fmt in remote_media.fmt): - raise NotImplementedError("Remote SDP offer does not include T.38") - return cls.sdp_media_description(port=remote_media.port) + if any(str(fmt.payload_type).lower() == "t38" for fmt in remote_media.fmt): + return cls.sdp_media_description(port=remote_media.port) + raise NotImplementedError("Remote SDP offer does not include T.38") @classmethod def sdp_formats(cls) -> list[RTPPayloadFormat]: - """Return the T.38 payload format descriptor.""" return [RTPPayloadFormat(payload_type="t38")] @classmethod def sdp_media_description(cls, port: int = 0) -> MediaDescription: - """Return the T.38 media description for outbound SDP. - - Args: - port: Local UDP port for T.38 UDPTL transport. - - Returns: - A `MediaDescription` with T.38/UDPTL parameters per [RFC 3362]. - - [RFC 3362]: https://datatracker.ietf.org/doc/html/rfc3362 - """ return MediaDescription( media="image", port=port, @@ -141,10 +122,6 @@ async def transmit(self) -> None: class InboundFaxSession(FaxSession): """Collect incoming T.38 UDPTL packets into a single document buffer. - Each UDPTL packet appended to `document` via - [document_received][voip.fax.InboundFaxSession.document_received]. - Override that method to process packets individually instead. - Attributes: document: Accumulated T.38 UDPTL data received so far. """ diff --git a/voip/mcp.py b/voip/mcp.py index 75f2083..4b5b9c8 100644 --- a/voip/mcp.py +++ b/voip/mcp.py @@ -1,6 +1,6 @@ """MCP server for VoIP actions. -Requires the ``mcp`` extra: ``pip install voip[mcp]``. +Requires the `mcp` extra: `pip install voip[mcp]`. """ import asyncio diff --git a/voip/rtp.py b/voip/rtp.py index 77d5772..7493700 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -96,13 +96,13 @@ class Session: 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. + (`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: - media_type: SDP media type (e.g. ``"audio"`` or ``"image"``). + media_type: SDP media type (e.g. `"audio"` or `"image"`). rtp: Shared RTP multiplexer socket that delivers packets to this handler. dialog: SIP dialog state for this call leg. media: Negotiated SDP media description for this call leg. @@ -123,7 +123,7 @@ def packet_received(self, packet: RTPPacket, addr: NetworkAddress) -> None: Args: packet: Parsed RTP packet. - addr: Remote ``(host, port)`` the packet arrived from. + addr: Remote `(host, port)` the packet arrived from. """ def data_received(self, data: bytes, addr: NetworkAddress) -> None: @@ -135,7 +135,7 @@ def data_received(self, data: bytes, addr: NetworkAddress) -> None: Args: data: Raw datagram payload. - addr: Source ``(host, port)`` of the datagram. + addr: Source `(host, port)` of the datagram. """ def send_packet(self, packet: RTPPacket, addr: NetworkAddress) -> None: @@ -145,7 +145,7 @@ def send_packet(self, packet: RTPPacket, addr: NetworkAddress) -> None: Args: packet: RTP packet to send. - addr: Destination ``(host, port)``. + addr: Destination `(host, port)`. """ data = bytes(packet) if self.srtp is not None: @@ -187,7 +187,7 @@ def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: propagates and the call is not answered. Args: - remote_media: The SDP ``m=audio`` section from the remote INVITE. + remote_media: The SDP `m=audio` section from the remote INVITE. Returns: A `MediaDescription` with the chosen codec. @@ -222,7 +222,7 @@ def sdp_media_description(cls, port: int) -> MediaDescription: """Return the media description for outbound SDP offers. Override in subclasses to support alternative media types (e.g. T.38 - FAX uses ``m=image udptl t38`` instead of ``m=audio RTP/AVP``). + FAX uses `m=image udptl t38` instead of `m=audio RTP/AVP`). Args: port: Local port number for the media stream. @@ -249,12 +249,12 @@ class RealtimeTransportProtocol(STUNProtocol): matching handler's `datagram_received` method by remote source address. - Use ``addr=None`` in `register_call` as a wildcard catch-all for + Use `addr=None` in `register_call` as a wildcard catch-all for calls whose remote RTP address is not known in advance (no SDP in INVITE). """ rtp_header_size: typing.ClassVar[int] = 12 - calls: dict[tuple[str, int] | None, Session] = dataclasses.field( + calls: dict[NetworkAddress | None, Session] = dataclasses.field( init=False, default_factory=dict ) public_address: NetworkAddress | None = dataclasses.field(init=False, default=None) @@ -273,13 +273,13 @@ def register_call( ) -> None: """Register *handler* for RTP traffic arriving from *addr*. - Use ``addr=None`` as a wildcard to handle traffic from any source that + Use `addr=None` as a wildcard to handle traffic from any source that has no dedicated routing entry (useful when the caller's RTP address is not known in advance from the INVITE SDP). Args: - addr: Remote ``(ip, port)`` as it will appear in incoming datagrams, - or ``None`` to register a wildcard catch-all handler. + addr: Remote `(ip, port)` as it will appear in incoming datagrams, + or `None` to register a wildcard catch-all handler. handler: A `Call` instance whose `datagram_received` will be called for matching packets. @@ -319,7 +319,7 @@ def packet_received(self, data: bytes, addr: NetworkAddress) -> None: """Route an incoming SRTP datagram to the matching per-call handler. Looks up *addr* in the call registry. Falls back to the wildcard - ``None`` handler when no exact match exists. Drops the packet with a + `None` handler when no exact match exists. Drops the packet with a debug log when no handler is registered at all. When the matched handler carries an SRTP session the packet is diff --git a/voip/sdp/messages.py b/voip/sdp/messages.py index 57265c9..20acf84 100644 --- a/voip/sdp/messages.py +++ b/voip/sdp/messages.py @@ -110,8 +110,8 @@ def _apply_line( def _apply_media_attribute(attr: Attribute, media: MediaDescription) -> bool: """Fold a media-level a= attribute into *media* if it is a format-specific attribute. - Returns ``True`` when the attribute was consumed (``a=rtpmap`` or - ``a=fmtp``), ``False`` otherwise so the caller can fall through to the + Returns `True` when the attribute was consumed (`a=rtpmap` or + `a=fmtp`), `False` otherwise so the caller can fall through to the generic attribute list. """ return media.apply_attribute(attr) diff --git a/voip/sdp/types.py b/voip/sdp/types.py index 0c97755..d59753c 100644 --- a/voip/sdp/types.py +++ b/voip/sdp/types.py @@ -265,11 +265,11 @@ def from_pt(cls, pt: int) -> StaticPayloadType: class RTPPayloadFormat(ByteSerializableObject): """RTP payload format descriptor (RFC 3551 §6 / RFC 4566 §6). - Codec parameters from ``a=rtpmap`` are merged in by the SDP parser. + Codec parameters from `a=rtpmap` are merged in by the SDP parser. Static payload types fall back to the `StaticPayloadType` table. - Dynamic payload types (PT ≥ 96) require an explicit ``a=rtpmap``. + Dynamic payload types (PT ≥ 96) require an explicit `a=rtpmap`. - Serialises to the ``a=rtpmap`` value when codec fields are present. + Serialises to the `a=rtpmap` value when codec fields are present. """ payload_type: int | str @@ -347,7 +347,7 @@ class MediaDescription(ByteSerializableObject): attributes: list[Attribute] = dataclasses.field(default_factory=list) def get_format(self, pt: int | str) -> RTPPayloadFormat | None: - """Return the `RTPPayloadFormat` for payload type *pt*, or ``None``.""" + """Return the `RTPPayloadFormat` for payload type *pt*, or `None`.""" try: target: int | str = int(pt) except TypeError, ValueError: @@ -355,9 +355,9 @@ def get_format(self, pt: int | str) -> RTPPayloadFormat | None: return next((f for f in self.fmt if f.payload_type == target), None) def apply_attribute(self, attr: Attribute) -> bool: - """Apply a media-level ``a=`` attribute, returning ``True`` if consumed. + """Apply a media-level `a=` attribute, returning `True` if consumed. - Handles ``a=rtpmap`` and ``a=fmtp`` by updating the matching + Handles `a=rtpmap` and `a=fmtp` by updating the matching `RTPPayloadFormat` entry. Other attributes go to `attributes`. """ if attr.name == "rtpmap" and attr.value is not None: diff --git a/voip/sip/dialog.py b/voip/sip/dialog.py index 5b2ba7c..67d9456 100644 --- a/voip/sip/dialog.py +++ b/voip/sip/dialog.py @@ -187,7 +187,7 @@ async def dial( 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"``). + target: SIP or tel URI of the remote party (e.g. `"sip:+15551234567@carrier.com"` or `"tel:+15551234567"`). session_class: Session subclass to create for this call. **session_kwargs: Extra keyword arguments forwarded to `session_class`. diff --git a/voip/sip/exceptions.py b/voip/sip/exceptions.py index a78f9b5..605d309 100644 --- a/voip/sip/exceptions.py +++ b/voip/sip/exceptions.py @@ -2,5 +2,5 @@ class RegistrationError(Exception): """Raised when a SIP REGISTER request fails with an unexpected response. The exception message includes the response status code and reason phrase - from the server, e.g. ``"403 Forbidden"`` or ``"500 Server Error"``. + from the server, e.g. `"403 Forbidden"` or `"500 Server Error"`. """ diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index ef549e6..5c31e54 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -91,7 +91,7 @@ async def main(): 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``. + `486 Busy Here`. keepalive_interval: Keep-alive ping interval. Should be between 30 and 90 seconds. """ @@ -149,16 +149,16 @@ async def run( fn: Called when the SIP session is registered, before `run` returns. Receives no arguments. May use [`asyncio.create_task`][] for async work. - aor: SIP Address of Record, e.g. ``sip:alice@carrier.example``. - The host, port, and ``transport`` parameter are used to connect + aor: SIP Address of Record, e.g. `sip:alice@carrier.example`. + The host, port, and `transport` parameter are used to connect to the SIP proxy. dialog_class: [`Dialog`][voip.sip.Dialog] subclass used for inbound calls. Defaults to the base [`Dialog`][voip.sip.Dialog], which rejects all calls. no_verify_tls: Disable TLS certificate verification. Insecure; for - testing only. Defaults to ``False``. + testing only. Defaults to `False`. stun_server: STUN server for RTP NAT traversal. Defaults to - ``stun.cloudflare.com:3478``. + `stun.cloudflare.com:3478`. Returns: The registered [`SessionInitiationProtocol`][voip.sip.protocol.SessionInitiationProtocol] @@ -167,8 +167,8 @@ async def run( loop = asyncio.get_running_loop() rtp_bind_address = ( - "::" if isinstance(aor.maddr[0], ipaddress.IPv6Address) else "0.0.0.0" - ) # noqa: S104 + "::" if isinstance(aor.maddr[0], ipaddress.IPv6Address) else "0.0.0.0" # noqa: S104 + ) _, rtp_protocol = await loop.create_datagram_endpoint( lambda: RealtimeTransportProtocol(stun_server_address=stun_server), local_addr=(rtp_bind_address, 0), @@ -193,7 +193,7 @@ async def run( return protocol def register_dialog(self, dialog: Dialog) -> None: - """Register *dialog* keyed by ``(dialog.local_tag, dialog.remote_tag)``.""" + """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: @@ -441,14 +441,14 @@ def on_registered(self) -> None: @property def contact(self) -> str: - """Return a ``Contact:`` header value for this UA. + """Return a `Contact:` header value for this UA. - The URI scheme mirrors `aor`: a ``sips:`` AOR produces a - ``sips:`` Contact (the strongest TLS guarantee); a ``sip:`` AOR over - TLS produces ``sip:`` with ``transport=tls``; plain TCP produces plain - ``sip:``. + The URI scheme mirrors `aor`: a `sips:` AOR produces a + `sips:` Contact (the strongest TLS guarantee); a `sip:` AOR over + TLS produces `sip:` with `transport=tls`; plain TCP produces plain + `sip:`. - When *ob* is ``True`` the ``ob`` URI parameter ([RFC 5626 §5]) is + When *ob* is `True` the `ob` URI parameter ([RFC 5626 §5]) is appended inside the angle brackets to advertise outbound keep-alive support to the registrar. diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 9d8a71e..f83362b 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -52,7 +52,7 @@ class Transaction(asyncio.Future): """ Initiated by a request, completed by any number of responses. - Transactions are awaitable: ``await tx`` suspends until the transaction + Transactions are awaitable: `await tx` suspends until the transaction reaches its terminal state and resolves to the dialog. Args: @@ -282,7 +282,7 @@ def parse_auth_challenge(header: str) -> dict[str, str]: """Parse Digest challenge parameters from a WWW-Authenticate/Proxy-Authenticate header. Args: - header: The raw ``WWW-Authenticate`` or ``Proxy-Authenticate`` header value. + header: The raw `WWW-Authenticate` or `Proxy-Authenticate` header value. Returns: A dict mapping parameter names to their unquoted values. @@ -313,27 +313,27 @@ def digest_response( """Compute a SIP digest response per RFC 3261 §22 and RFC 8760. RFC 8760 deprecates MD5 and mandates support for SHA-256 and - SHA-512-256. The ``algorithm`` parameter selects the hash function; - it defaults to ``SHA-256``. + SHA-512-256. The `algorithm` parameter selects the hash function; + it defaults to `SHA-256`. Args: username: SIP username (AOR user part). password: SIP password. realm: Digest realm from the challenge. nonce: Digest nonce from the challenge. - method: SIP method string (e.g. ``"REGISTER"``). + method: SIP method string (e.g. `"REGISTER"`). uri: Request-URI string used in the digest. - algorithm: Digest algorithm identifier (default: ``"SHA-256"``). - qop: Quality-of-protection value, or ``None``. - nc: Nonce count hex string (default: ``"00000001"``). - cnonce: Client nonce, required for ``*-sess`` algorithms and ``qop``. + algorithm: Digest algorithm identifier (default: `"SHA-256"`). + qop: Quality-of-protection value, or `None`. + nc: Nonce count hex string (default: `"00000001"`). + cnonce: Client nonce, required for `*-sess` algorithms and `qop`. Returns: Hex-encoded digest response string. Raises: - ValueError: If ``algorithm`` is not a recognised `DigestAlgorithm`, - or if a ``*-sess`` algorithm is requested without a ``cnonce``. + ValueError: If `algorithm` is not a recognised `DigestAlgorithm`, + or if a `*-sess` algorithm is requested without a `cnonce`. """ try: hash_name = cls.DIGEST_HASH_NAME[algorithm] @@ -636,7 +636,7 @@ async def send( Args: sip: The SIP session to send from. - target: SIP or tel URI of the callee (e.g. ``"sip:+15551234567@carrier.com"`` or ``"tel:+15551234567"``). + 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 diff --git a/voip/sip/types.py b/voip/sip/types.py index cd5a886..29670ac 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -43,7 +43,7 @@ class SipURI(str): 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 + (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 @@ -203,8 +203,8 @@ def transport(self): class CallerID(str): """SIP From/To header value with structured access and privacy-safe repr. - Behaves as a plain ``str`` so it is wire-format compatible and can be - stored in header dicts unchanged. ``repr()`` returns a short anonymized + Behaves as a plain `str` so it is wire-format compatible and can be + stored in header dicts unchanged. `repr()` returns a short anonymized form that shows only the last four characters of the user part and the carrier domain — useful for log messages. diff --git a/voip/stun.py b/voip/stun.py index e19311e..70486b5 100644 --- a/voip/stun.py +++ b/voip/stun.py @@ -43,11 +43,11 @@ def _parse_address( Args: value: Raw attribute value bytes (everything after the type/length TLV header). xor_key: XOR key bytes — must be exactly 16 bytes - (``MAGIC_COOKIE (4 bytes) || transaction_id (12 bytes)``) + (`MAGIC_COOKIE (4 bytes) || transaction_id (12 bytes)`) for XOR-MAPPED-ADDRESS, or empty bytes for plain MAPPED-ADDRESS. Returns: - ``(ip_address, port)`` on success, ``None`` when *value* is + `(ip_address, port)` on success, `None` when *value* is too short or the address family is unrecognised. """ assert not xor_key or len(xor_key) == 16, "xor_key must be 16 bytes or empty" # noqa: S101 @@ -191,7 +191,7 @@ def packet_received(self, data: bytes, addr: NetworkAddress) -> None: Args: data: Raw datagram payload (first byte ≥ 4, not a STUN packet). - addr: Source ``(host, port)`` of the datagram. + addr: Source `(host, port)` of the datagram. """ def _send_stun_request(self) -> None: From 766f654fb03faa22b2b3522ab36f9b8c0501bb42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:32:36 +0000 Subject: [PATCH 8/8] Add MIME type support for PDF and plain text in fax sessions --- tests/test_fax.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_mcp.py | 42 +++++++++++++++++++++++++++++++++++++----- voip/fax.py | 3 +++ voip/mcp.py | 23 +++++++++++++++++++---- 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/tests/test_fax.py b/tests/test_fax.py index 152f425..09f4f98 100644 --- a/tests/test_fax.py +++ b/tests/test_fax.py @@ -153,6 +153,41 @@ def test_send_document__logs_warning_when_no_remote_address(self, caplog) -> Non class TestOutboundFaxSession: + def test_mime_type__defaults_to_octet_stream(self) -> None: + """mime_type defaults to application/octet-stream when not specified.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_dialog = MagicMock(spec=Dialog) + mock_dialog.sip = None + + with patch("asyncio.create_task"): + session = OutboundFaxSession( + rtp=mock_rtp, + dialog=mock_dialog, + media=FaxSession.sdp_media_description(), + caller=CallerID(""), + document=b"data", + ) + + assert session.mime_type == "application/octet-stream" + + def test_mime_type__can_be_set(self) -> None: + """mime_type can be provided explicitly.""" + mock_rtp = MagicMock(spec=RealtimeTransportProtocol) + mock_dialog = MagicMock(spec=Dialog) + mock_dialog.sip = None + + with patch("asyncio.create_task"): + session = OutboundFaxSession( + rtp=mock_rtp, + dialog=mock_dialog, + media=FaxSession.sdp_media_description(), + caller=CallerID(""), + document=b"%PDF-1.4", + mime_type="application/pdf", + ) + + assert session.mime_type == "application/pdf" + async def test_transmit__sends_document_and_hangs_up(self) -> None: """Transmit sends the document, hangs up, and closes the SIP connection.""" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 2d4989e..b8c09f2 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -274,8 +274,8 @@ async def test_send_fax__dials_with_document_path(self, tmp_path) -> None: mock_sip.aor = aor connection_pool.sip = mock_sip - doc = tmp_path / "fax.txt" - doc.write_bytes(b"fax content") + doc = tmp_path / "fax.pdf" + doc.write_bytes(b"%PDF-1.4") ctx = make_mock_context() target_uri = SipURI.parse("sip:bob@carrier.example") @@ -292,7 +292,8 @@ async def test_send_fax__dials_with_document_path(self, tmp_path) -> None: _, kwargs = mock_dialog.dial.call_args assert kwargs["session_class"].__name__ == "OutboundFaxSession" - assert kwargs["document"] == b"fax content" + assert kwargs["document"] == b"%PDF-1.4" + assert kwargs["mime_type"] == "application/pdf" async def test_send_fax__dials_with_text(self) -> None: """send_fax() encodes text as UTF-8 and dials with OutboundFaxSession.""" @@ -315,6 +316,7 @@ async def test_send_fax__dials_with_text(self) -> None: _, kwargs = mock_dialog.dial.call_args assert kwargs["document"] == b"Hello" + assert kwargs["mime_type"] == "text/plain" async def test_send_fax__document_path_takes_precedence(self, tmp_path) -> None: """send_fax() prefers document_path over text when both are given.""" @@ -325,6 +327,37 @@ async def test_send_fax__document_path_takes_precedence(self, tmp_path) -> None: mock_sip.aor = aor connection_pool.sip = mock_sip + doc = tmp_path / "fax.pdf" + doc.write_bytes(b"%PDF-1.4") + ctx = make_mock_context() + + with patch("voip.mcp.parse_uri", return_value=aor): + with patch("voip.mcp.Dialog") as MockDialog: + mock_dialog = MagicMock(spec=Dialog) + MockDialog.return_value = mock_dialog + mock_dialog.dial = AsyncMock() + await send_fax( + ctx=ctx, + target="sip:bob@carrier.example", + text="ignored", + document_path=str(doc), + ) + + _, kwargs = mock_dialog.dial.call_args + assert kwargs["document"] == b"%PDF-1.4" + assert kwargs["mime_type"] == "application/pdf" + + async def test_send_fax__unknown_extension_uses_octet_stream( + self, tmp_path + ) -> None: + """send_fax() falls back to application/octet-stream for unknown file types.""" + from voip.mcp import send_fax # noqa: PLC0415 + + aor = SipURI.parse("sip:alice@carrier.example") + mock_sip = MagicMock(spec=SessionInitiationProtocol) + mock_sip.aor = aor + connection_pool.sip = mock_sip + doc = tmp_path / "fax.bin" doc.write_bytes(b"binary doc") ctx = make_mock_context() @@ -337,12 +370,11 @@ async def test_send_fax__document_path_takes_precedence(self, tmp_path) -> None: await send_fax( ctx=ctx, target="sip:bob@carrier.example", - text="ignored", document_path=str(doc), ) _, kwargs = mock_dialog.dial.call_args - assert kwargs["document"] == b"binary doc" + assert kwargs["mime_type"] == "application/octet-stream" async def test_send_fax__raises_when_no_content(self) -> None: """send_fax() raises ValueError when neither text nor document_path is given.""" diff --git a/voip/fax.py b/voip/fax.py index 116a742..9f2630d 100644 --- a/voip/fax.py +++ b/voip/fax.py @@ -103,9 +103,12 @@ class OutboundFaxSession(FaxSession): Attributes: document: Raw document bytes to transmit as a T.38 FAX. + mime_type: MIME type of the document, + e.g. `"application/pdf"` or `"text/plain"`. """ document: bytes + mime_type: str = "application/octet-stream" def __post_init__(self) -> None: asyncio.create_task(self.transmit()) diff --git a/voip/mcp.py b/voip/mcp.py index 4b5b9c8..89f9127 100644 --- a/voip/mcp.py +++ b/voip/mcp.py @@ -5,6 +5,7 @@ import asyncio import dataclasses +import mimetypes import pathlib import threading import typing @@ -116,6 +117,10 @@ async def send_fax( `text` (encoded as UTF-8) as a T.38 FAX, then hangs up. When both are provided, `document_path` takes precedence. + The MIME type is detected automatically from the file extension + (e.g. `"application/pdf"` for `.pdf`, `"text/plain"` for `.txt`). + Plain `text` is always sent with MIME type `"text/plain"`. + Args: ctx: FastMCP context (injected automatically by the framework). target: Phone number or SIP URI to call, e.g. `"tel:+1234567890"` @@ -127,12 +132,22 @@ async def send_fax( raise RuntimeError("VoIP not connected: call run() before using tools.") if not document_path and not text: raise ValueError("Provide either text or document_path.") - document = ( - pathlib.Path(document_path).read_bytes() if document_path else text.encode() - ) + if document_path: + path = pathlib.Path(document_path) + document = path.read_bytes() + mime_type, _ = mimetypes.guess_type(str(path)) + mime_type = mime_type or "application/octet-stream" + else: + document = text.encode() + mime_type = "text/plain" target_uri = parse_uri(target, connection_pool.sip.aor) dialog = Dialog(sip=connection_pool.sip) - await dialog.dial(target_uri, session_class=OutboundFaxSession, document=document) + await dialog.dial( + target_uri, + session_class=OutboundFaxSession, + document=document, + mime_type=mime_type, + ) @mcp.tool