Skip to content

Add outbound calling ability with proper SIP BYE hang-up (dialog-centric refactor)#65

Merged
codingjoe merged 45 commits intomainfrom
outbound
Apr 7, 2026
Merged

Add outbound calling ability with proper SIP BYE hang-up (dialog-centric refactor)#65
codingjoe merged 45 commits intomainfrom
outbound

Conversation

@codingjoe
Copy link
Copy Markdown
Owner

@codingjoe codingjoe commented Mar 29, 2026

Adds outbound calling support and a proper SIP BYE transaction for ending dialogs, with a major architectural refactoring to make Dialog the primary extension point for application logic.

Changes Made

  • Outbound calling: Dialog.dial() initiates an outbound call. Users create a Dialog instance and call await dialog.dial(target, call_class=MyCall).
  • tel: URI support via SipURI.parse: SipURI.parse now accepts tel: URIs and converts them to SipURI per RFC 3261 §19.1.6 — the phone number becomes the user part and the phone-context domain becomes the host (with user=phone parameter). For global numbers without a phone-context, the CLI falls back to using the AOR host. TelURI is no longer used anywhere in the public API; all dial targets and header URIs are represented as SipURI throughout the codebase.
  • Transactions inherit asyncio.Future: Transaction now inherits from asyncio.Future directly. await tx resolves to the dialog when the transaction reaches its terminal state. A complete() helper centralises resolution. The old done event attribute and ByeTransaction.acknowledged are replaced by the unified Future interface.
  • cseq always explicit: Transaction.cseq has no default — every construction site (factory classmethods, from_request, registration) passes the sequence number explicitly, always derived from the dialog.
  • Symmetric send/receive factory classmethods: InviteTransaction and ByeTransaction each expose a send() coroutine (UAC side, builds and sends the request) and a receive() coroutine (UAS side, handles an incoming request). All SIP message construction lives inside these factory methods; __init__/__post_init__ does no I/O.
  • dialog.route_set populated: Record-Route headers from the INVITE (inbound) and 200 OK (outbound, reversed per RFC 3261 §12.1.2) are stored in dialog.route_set and included as Route headers in subsequent requests (BYE, ACK).
  • Dialog owns the call lifecycle: Dialog is the primary extension point, with call_received(), accept(), reject(), ringing(), bye(), dial(), and hangup_received() methods. InviteTransaction.receive() delegates to dialog.call_received(); ByeTransaction.receive() delegates to dialog.hangup_received(). Dialog.bye() and Dialog.dial() use the factory classmethods directly. Dialog cleanup happens after the BYE transaction completes.
  • dialog_class replaces transaction_class: SessionInitiationProtocol now has dialog_class: type[Dialog] = Dialog. The base Dialog.call_received() rejects with 486 by default; subclass and override to accept calls.
  • on_registered decoupled from RegistrationTransaction: RegistrationTransaction.response_received resolves the Future with the dialog on success; auth-retry chains via forward_result() callback. A new SessionInitiationProtocol.handle_registration() coroutine awaits the tx and calls on_registered.
  • Protocol routing fixed: request_received has an explicit match arm per method — INVITEInviteTransaction.receive() task; ACK → routes to the existing dialog.invite_transaction.ack_received(); BYEByeTransaction.receive() task; CANCEL → existing invite tx; OPTIONS → 200 OK; unknown → 405.
  • allowed_methods hardcoded: Returns a frozenset literal instead of dynamic method introspection.
  • Session only accesses RTP + Dialog: Session.sip field removed. Session.hang_up() deregisters the RTP handler then delegates to dialog.bye(). BYE_ACK_TIMEOUT lives on Dialog.
  • SayCall clean termination: SayCall.on_audio_sent() schedules hang_up() via asyncio.create_task. SayCall.hang_up() awaits the BYE acknowledgment then closes the SIP transport.
  • CLI updated: __main__.py uses Dialog subclasses (EchoDialog, TranscribeDialog, AgentDialog, OutboundDialog) instead of InviteTransaction subclasses.

Documentation

  • Updated docs/cookbook.md to show the new Dialog-subclass pattern, including "Hanging Up a Call" and "Making Outbound Calls" recipes.
  • Added Dialog API reference section to docs/sessions.md.

@codingjoe codingjoe self-assigned this Mar 29, 2026
@codingjoe codingjoe linked an issue Mar 29, 2026 that may be closed by this pull request
@codingjoe codingjoe force-pushed the outbound branch 3 times, most recently from 4dd5e23 to af74a20 Compare March 30, 2026 13:49
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 30, 2026

Codecov Report

❌ Patch coverage is 48.86165% with 292 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.28%. Comparing base (3e0cf9b) to head (92ea1bb).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
voip/sip/transactions.py 16.42% 117 Missing ⚠️
voip/__main__.py 0.00% 68 Missing ⚠️
voip/sip/protocol.py 27.45% 37 Missing ⚠️
voip/ai.py 0.00% 30 Missing ⚠️
voip/sip/dialog.py 74.60% 16 Missing ⚠️
voip/rtp.py 25.00% 9 Missing ⚠️
voip/sip/types.py 94.70% 8 Missing ⚠️
voip/audio.py 0.00% 7 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main      #65       +/-   ##
===========================================
- Coverage   87.66%   61.28%   -26.39%     
===========================================
  Files          26       27        +1     
  Lines        1954     2291      +337     
===========================================
- Hits         1713     1404      -309     
- Misses        241      887      +646     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@codingjoe
Copy link
Copy Markdown
Owner Author

@copilot we are missing a correct BYE transaction to end a dialog. Currently SayCall.on_audio_sent simply shuts down the SIP transport. However, need a proper hang up function, that can also be called programmatically when someone implements the library. Please document how to do that too.

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 30, 2026

@copilot we are missing a correct BYE transaction to end a dialog. Currently SayCall.on_audio_sent simply shuts down the SIP transport. However, need a proper hang up function, that can also be call...

Implemented in f8d7fa7.

Session.hang_up() now sends a proper RFC 3261 §15 BYE request, removes the dialog from the registry, and deregisters the RTP handler. It does not close the SIP transport so the connection can serve multiple calls.

For programmatic hang-up:

class MyCall(AudioCall):
    async def voice_received(self, audio: np.ndarray) -> None:
        await self.hang_up()
        self.sip.close()  # only if you want to close the entire SIP session

SayCall overrides hang_up() to also call sip.close() since it's a single-shot outbound call. on_audio_sent() now schedules hang_up() via asyncio.create_task instead of directly calling sip.close().

The dialog fields local_party, remote_party, and outbound_cseq are populated by the transaction layer (InviteTransaction.answer() for inbound, _accept_call() for outbound) and stored on Session.dialog so hang_up() can construct correct BYE headers. A "Hanging Up a Call" section has been added to docs/cookbook.md with a full example.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • astral.sh
    • Triggering command: /usr/bin/curl curl -LsSf REDACTED (dns block)
  • releases.astral.sh
    • Triggering command: /home/REDACTED/.local/bin/uv /home/REDACTED/.local/bin/uv run pytest tests/sip/ -q --tb=short (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot AI changed the title Add outbound calling ablity Add outbound calling ability with proper SIP BYE hang-up Mar 30, 2026
@codingjoe
Copy link
Copy Markdown
Owner Author

@copilot the bye should be acknowledged, right?

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 30, 2026

@copilot the bye should be acknowledged, right?

Yes — added in 0370426. ByeTransaction (RFC 3261 §17.1.2) is now registered in sip.transactions before the BYE is sent, so the remote party's 200 OK is routed back to it. On receipt of any final (2xx+) response it removes itself from the transaction table; 1xx provisionals are silently ignored. Unlike INVITE, BYE responses require no ACK — the 200 OK is the full acknowledgment.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • releases.astral.sh
    • Triggering command: /home/REDACTED/.local/bin/uv /home/REDACTED/.local/bin/uv run pytest tests/sip/test_transactions.py tests/test_rtp.py -q --tb=short (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@codingjoe
Copy link
Copy Markdown
Owner Author

@copilot when we hang up or in the say command exit the CLI we should wait for the acknowledgement on the BYE, to make sure the UAS received it.

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 30, 2026

@copilot when we hang up or in the say command exit the CLI we should wait for the acknowledgement on the BYE, to make sure the UAS received it.

Done in c834a4e. Session.hang_up() now awaits ByeTransaction.acknowledged (an asyncio.Event set when the 200 OK arrives) before returning, using the standard 64×T1 = 32 s non-INVITE timeout (RFC 3261 §17.1.2). If the remote side doesn't respond in time, a warning is logged and hang_up() continues. Since SayCall.hang_up() calls await super().hang_up() before closing the transport, the say command also waits for the ack before shutting down. The timeout is configurable via Session.BYE_ACK_TIMEOUT.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • releases.astral.sh
    • Triggering command: /home/REDACTED/.local/bin/uv uv run pytest tests/test_rtp.py::TestSession -q --tb=short (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@codingjoe
Copy link
Copy Markdown
Owner Author

@copilot this needs some refactoring. Instead of using futures on attributes on the transaction objects, we should consider either inheriting from a future or implementing __await__ on all transactions. Since all transactions require multiple async messages (asyncio) and have a finite state, this will provide a cleaner interface.

Furthermore, we need to strengthen the concept of a dialogue. We should move application-specific logic from the transactions to the dialog. As a user of this library: If I want to dial out, I create a dialog. If a call comes in, I provide the SIP handler with a dialog class, that decides whether to accept, reject the call.

The session should have only have direct access to the RTP protocol/transport and the dialog and not call methods on the SIP handler directly.

This is a pretty big refactoring, think hard and take your time.

…class replaces transaction_class

Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/07b67480-7d2f-4917-8834-5b7f6f40dae9

Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com>
@codingjoe codingjoe marked this pull request as ready for review April 3, 2026 16:57
Copilot AI review requested due to automatic review settings April 3, 2026 16:57
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a dialog-centric SIP architecture to support outbound calling and proper dialog termination via SIP BYE, alongside broad refactors across SIP message/transaction handling, RTP session lifecycle, CLI usage patterns, and documentation.

Changes:

  • Refactors SIP call lifecycle to center around a new Dialog API, with INVITE/BYE handled via transaction factory methods and protocol routing updates.
  • Adds outbound call initiation (Dialog.dial) and call termination (Dialog.bye), and updates RTP Session.hang_up() to delegate BYE signaling to dialogs.
  • Updates CLI and docs to reflect the new dialog/session model, and adjusts parsing/types to represent URIs uniformly.

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated 28 comments.

Show a summary per file
File Description
voip/stun.py Refactors address handling in STUN; updates callback signature to NetworkAddress.
voip/sip/types.py Reworks SIP URI representation and adds parse_uri; extends CallerID helpers and status enum docs.
voip/sip/messages.py Introduces SIPHeaderDict and refactors SIP message parsing/serialization.
voip/sip/transactions.py Refactors transactions to be awaitable futures; adds outbound INVITE and BYE transaction flows.
voip/sip/protocol.py Updates protocol routing to method-specific handling and dialog-centric registration hooks.
voip/sip/dialog.py Adds new Dialog class as primary SIP extension point and lifecycle owner.
voip/sip/init.py Updates SIP package exports for dialog-centric API.
voip/rtp.py Moves SIP teardown control to dialogs (Session.dialog), adds outbound SDP format advertising.
voip/audio.py Adjusts session API (session_class), adds sdp_formats and on_audio_sent hook.
voip/ai.py Refactors TTS into a mixin; adds SayCall; updates agent behavior and exports.
voip/main.py Updates CLI to use Dialog subclasses and adds outbound dialing (--dial, say).
voip/codecs/pcmu.py Docstring link formatting adjustments.
voip/codecs/pcma.py Docstring link formatting adjustments.
voip/codecs/opus.py Docstring link formatting adjustments.
voip/codecs/g722.py Docstring link formatting adjustments.
voip/codecs/base.py Docstring link formatting adjustments.
voip/codecs/av.py Docstring link formatting adjustments.
voip/codecs/init.py Docstring link formatting adjustments.
tests/test_rtp.py Updates RTP tests to use Dialog reference instead of sip.
tests/test_audio.py Removes prior audio test suite.
tests/test_ai.py Removes prior AI test suite.
tests/sip/test_types.py Updates tests for new URI type and new CallerID.uri behavior.
tests/sip/test_transactions.py Removes prior transaction test suite.
tests/sip/test_protocol.py Removes prior protocol test suite.
tests/sip/test_messages.py Updates tests for new header map and message parsing behavior.
tests/sip/conftest.py Updates SIP fixture to use dialog_class and new URI type.
README.md Updates CLI/docs text and partial API example changes.
pyproject.toml Enables --doctest-modules for pytest runs.
mkdocs.yml Reorganizes nav and mkdocstrings/autorefs configuration.
docs/sip.md Updates SIP docs to render Dialog and type/reference sections.
docs/sessions.md Updates sessions docs and adds SayCall reference.
docs/sdp.md Removes SDP doc page content.
docs/rtp.md Removes RTP doc page content.
docs/cookbook.md Updates recipes for dialog/session model, hangup, outbound calling.
CONTRIBUTING.md Updates command block formatting and section ordering.
.pre-commit-config.yaml Sets default Python version and adds mdformat plugin dependency.

Comment thread voip/stun.py
Comment thread voip/sip/types.py
Comment thread voip/sip/types.py
Comment thread voip/sip/messages.py
Comment thread voip/sip/messages.py
Comment thread voip/sip/transactions.py
Comment thread voip/sip/protocol.py
Comment thread voip/__main__.py
Comment thread voip/rtp.py Outdated
Comment thread voip/sip/types.py
@codingjoe
Copy link
Copy Markdown
Owner Author

@copilot there are some unresolved review comments regarding response acknowledgement and dialog handling. Address them.

Please move the transaction and dialog handling to the sip protocol handler. Transaction should not mutate the SIP handler state, but use mutation methods. Like add_dialog del_dialog. Maybe use a leading underscore like so _dialogs to indicate that they are owned by the protocol and shouldn't be mutated outside of it.

Copilot AI and others added 2 commits April 4, 2026 17:21
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 4, 2026

@copilot there are some unresolved review comments regarding response acknowledgement and dialog handling. Address them.

Please move the transaction and dialog handling to the sip protocol handler....

Done in e784760 and 016661c.

  • dialogs and transactions renamed to _dialogs and _transactions to indicate protocol ownership.
  • add_dialog, del_dialog, add_transaction, del_transaction mutation methods added to SessionInitiationProtocol; all transactions.py / dialog.py code now uses these methods exclusively.
  • Addressed the unresolved review comments:
    • answer() now updates self.dialog (the sip.dialog_class instance) in-place instead of creating a new base Dialog; local_party tag correctly uses self.dialog.local_tag.
    • Dialog.from_request tag convention fixed: local_tag=our generated tag, remote_tag=their tag.
    • Double-ACK bug fixed: _start_call no longer calls ack() itself.
    • ack() now only sets dialog.remote_tag from the 200 response; local_tag stays as our original tag.
    • ACK routing tries branch-based lookup first (for non-2xx ACKs where the INVITE tx is still registered), then falls back to dialog lookup for 2xx ACKs.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • astral.sh
    • Triggering command: /usr/bin/curl curl -LsSf REDACTED (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@codingjoe
Copy link
Copy Markdown
Owner Author

pre-commit.ci run

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 15 comments.

Comment thread voip/sip/dialog.py
Comment on lines +208 to +213
return cls(
call_id=request.headers["Call-ID"],
local_tag=request.remote_tag or str(uuid.uuid4()),
remote_tag=request.local_tag,
remote_contact=request.headers.get("Contact"),
**kwargs,
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dialog.from_request() stores request.headers.get("Contact") directly into remote_contact, but headers parse Contact as a CallerID (which may include display name/angle brackets/params). Later BYE/ACK code treats remote_contact as a URI, so this can produce an invalid Request-URI. Extract and store the embedded URI (e.g. via CallerID(...).uri or by parsing the angle-bracket URI) instead of storing the full header value.

Copilot uses AI. Check for mistakes.
Comment thread voip/sip/transactions.py
Comment on lines +805 to +810
ack_branch = f"{Transaction.branch_prefix}-{uuid.uuid4()}"
contact = response.headers.get("Contact")
ack_uri = (
contact.split(";")[0].strip("<>") if contact else str(self.request.uri)
)
self.dialog.remote_contact = ack_uri
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outbound ACK URI derivation uses contact.split(";")[0].strip("<>"), but Contact is parsed as a CallerID and may include a display name (e.g. '"Alice" sip:alice@x'). In that case this produces an invalid ACK Request-URI. Extract the URI component (e.g. CallerID(contact).uri or regex for <...>), and store dialog.remote_contact as that URI.

Copilot uses AI. Check for mistakes.
Comment thread voip/sip/transactions.py
Comment on lines +693 to +702
headers={
"Max-Forwards": "70",
**tx.headers,
"From": dialog.from_header,
"To": str(target),
"Contact": sip.contact,
"Call-ID": dialog.call_id,
"Route": f"<sip:{str(rtp_public[0])}:5060;transport=tcp;lr>",
"Allow": sip.allow_header,
"Content-Type": "application/sdp",
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The outbound INVITE sets a Route header pointing at sip:{rtp_public_ip}:5060 (the RTP public address). Route is SIP signaling routing and should not be derived from RTP addressing; this is likely to misroute requests (possibly back to the UA itself). Consider omitting Route unless you have an outbound proxy/route-set, or populate it from the SIP registrar/proxy configuration / dialog route set.

Copilot uses AI. Check for mistakes.
Comment thread voip/sip/transactions.py
Comment on lines +873 to +878
headers: SIPHeaderDict = SIPHeaderDict(
{
"Via": (
f"SIP/2.0/{sip.aor.transport}"
f' {sip.rtp.public_address};oc-algo="loss";oc;rport;branch={tx.branch}'
),
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ByeTransaction.send() builds the Via header using sip.rtp.public_address, but other transactions use sip.public_address. Using the RTP socket address/port in Via is inconsistent and can break response routing; use the SIP transport/public address consistently for Via.

Copilot uses AI. Check for mistakes.
Comment thread voip/sip/protocol.py Outdated
Comment on lines +299 to +304
case SIPMethod.ACK:
# For non-2xx ACKs the INVITE tx is still present; route by branch.
tx = self._transactions[request.branch]
if isinstance(tx, InviteTransaction):
tx.ack_received(request)
return
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK handling routes by branch via self._transactions[request.branch] without guarding for missing branches. If an ACK arrives for an unknown/expired transaction, this will raise KeyError and can crash the protocol. Handle missing branches gracefully (e.g. get + warning) similar to the established-dialog fallback.

Copilot uses AI. Check for mistakes.
Comment thread voip/sip/dialog.py
Comment thread voip/sip/protocol.py
Comment thread voip/sip/transactions.py
Comment thread voip/sip/messages.py
Comment thread voip/sip/types.py
@codingjoe
Copy link
Copy Markdown
Owner Author

@copilot the cookbook.md is outdates, please updated the examples to meet the code changes

Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/cd23d7e2-9dfe-4452-a557-1803c71d6592

Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 7, 2026

@copilot the cookbook.md is outdates, please updated the examples to meet the code changes

Done in 92ea1bb. Updated the following cookbook examples:

  • Sending Audio to the Caller: _send_rtp_audiosend_audio, _resampleresample, removed the non-existent SAMPLE_RATE import and use self.sampling_rate_hz instead.
  • Hanging Up a Call: OneUtteranceCall now subclasses VoiceActivityCall (not AudioCall) since voice_received is defined on VoiceActivityCall.
  • Making Outbound Calls: dialog.dial(...) now uses SipURI.parse("sip:+15551234567@carrier.com") instead of a plain string, matching the target: SipURI type annotation.

Also fixed the Dialog docstring that incorrectly referenced self.accept() (the method is answer()).

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • releases.astral.sh
    • Triggering command: /home/REDACTED/.local/bin/uv uv run pytest tests/sip/ -x -q (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@codingjoe codingjoe merged commit dcf9823 into main Apr 7, 2026
14 of 16 checks passed
@codingjoe codingjoe deleted the outbound branch April 7, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support outbound calls

3 participants