Skip to content

Commit 7144413

Browse files
committed
Add outbound calling ablity
1 parent 3e0cf9b commit 7144413

15 files changed

Lines changed: 1831 additions & 211 deletions

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,24 @@ uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe
3030

3131
A simple echo server can be started with:
3232

33-
````console
3433
```console
3534
uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo
36-
````
35+
```
36+
37+
Each command supports an optional `--dial TARGET` flag to initiate an
38+
outbound call instead of waiting for an inbound one:
39+
40+
```console
41+
uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo --dial sip:+15551234567@sip.example.com
42+
uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe --dial sip:+15551234567@sip.example.com
43+
uvx 'voip[cli]' sip sips:alice:********@sip.example.com agent --dial sip:+15551234567@sip.example.com --initial-prompt "Hello, how can I help you?"
44+
```
45+
46+
To dial a number, say a message, and hang up automatically:
47+
48+
```console
49+
uvx 'voip[cli]' sip sips:alice:********@sip.example.com say sip:+15551234567@sip.example.com "Your package has arrived."
50+
```
3751

3852
You can also talk to a local agent (needs [Ollama]):
3953

tests/sip/test_messages.py

Lines changed: 79 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,43 @@
22

33
import pytest
44
from voip.sdp.messages import SessionDescription
5-
from voip.sip.messages import Dialog, Message, Request, Response
6-
from voip.sip.types import CallerID, SipUri
5+
from voip.sip import messages
6+
from voip.sip.types import SipUri
7+
8+
9+
class TestHeaderMap:
10+
def test_init(self):
11+
"""Initialize a HeaderMap with a dictionary of headers."""
12+
headers = messages.SIPHeaderDict(
13+
{"From": "Alice", "Route": "sip:proxy.example.com"}
14+
)
15+
assert headers["From"] == "Alice"
16+
assert headers["Route"] == "sip:proxy.example.com"
17+
18+
def test_init__empty(self):
19+
"""Initialize an empty HeaderMap."""
20+
headers = messages.SIPHeaderDict()
21+
assert headers == {}
22+
23+
def test__str__(self):
24+
"""String representation of a HeaderMap."""
25+
headers = messages.SIPHeaderDict()
26+
headers["From"] = "Alice"
27+
headers.add("Route", "sip:proxy.example.com")
28+
headers.add("Route", "sip:example.com")
29+
assert str(headers) == (
30+
"From: Alice\r\nRoute: sip:proxy.example.com\r\nRoute: sip:example.com\r\n"
31+
)
32+
33+
def test__bytes__(self):
34+
"""Byte representation of a HeaderMap."""
35+
headers = messages.SIPHeaderDict()
36+
headers["From"] = "Alice"
37+
headers.add("Route", "sip:proxy.example.com")
38+
headers.add("Route", "sip:example.com")
39+
assert bytes(headers) == (
40+
b"From: Alice\r\nRoute: sip:proxy.example.com\r\nRoute: sip:example.com\r\n"
41+
)
742

843

944
class TestMessage:
@@ -14,8 +49,8 @@ def test_parse__request(self):
1449
b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\r\n"
1550
b"\r\n"
1651
)
17-
result = Message.parse(data)
18-
assert isinstance(result, Request)
52+
result = messages.Message.parse(data)
53+
assert isinstance(result, messages.Request)
1954
assert result.method == "INVITE"
2055
assert result.uri == "sip:bob@biloxi.com"
2156
assert result.version == "SIP/2.0"
@@ -32,15 +67,15 @@ def test_parse__request__with_sdp_body(self):
3267
b"Content-Type: application/sdp\r\n"
3368
b"\r\n" + sdp
3469
)
35-
result = Message.parse(data)
36-
assert isinstance(result, Request)
70+
result = messages.Message.parse(data)
71+
assert isinstance(result, messages.Request)
3772
assert isinstance(result.body, SessionDescription)
3873

3974
def test_parse__request__without_sdp_content_type(self):
4075
"""Return None body when Content-Type is not application/sdp."""
4176
data = b"INVITE sip:bob@biloxi.com SIP/2.0\r\nContent-Length: 4\r\n\r\ntest"
42-
result = Message.parse(data)
43-
assert isinstance(result, Request)
77+
result = messages.Message.parse(data)
78+
assert isinstance(result, messages.Request)
4479
assert result.body is None
4580

4681
def test_parse__response(self):
@@ -50,8 +85,8 @@ def test_parse__response(self):
5085
b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds\r\n"
5186
b"\r\n"
5287
)
53-
result = Message.parse(data)
54-
assert isinstance(result, Response)
88+
result = messages.Message.parse(data)
89+
assert isinstance(result, messages.Response)
5590
assert result.status_code == 200
5691
assert result.phrase == "OK"
5792
assert result.version == "SIP/2.0"
@@ -64,49 +99,27 @@ def test_parse__response__with_sdp_body(self):
6499
"""Parse a SIP response with an SDP body from bytes."""
65100
sdp = b"v=0\r\ns=-\r\nt=0 0\r\n"
66101
data = b"SIP/2.0 200 OK\r\nContent-Type: application/sdp\r\n\r\n" + sdp
67-
result = Message.parse(data)
68-
assert isinstance(result, Response)
102+
result = messages.Message.parse(data)
103+
assert isinstance(result, messages.Response)
69104
assert isinstance(result.body, SessionDescription)
70105

71106
def test_parse__roundtrip_request(self):
72107
"""Round-trip a SIP request through parse and bytes."""
73-
request = Request(
108+
request = messages.Request(
74109
method="REGISTER",
75110
uri="sip:registrar.biloxi.com",
76111
headers={"From": "sip:bob@biloxi.com"},
77112
)
78-
assert Message.parse(bytes(request)) == request
113+
assert messages.Message.parse(bytes(request)) == request
79114

80115
def test_parse__roundtrip_response(self):
81116
"""Round-trip a SIP response through parse and bytes."""
82-
response = Response(
117+
response = messages.Response(
83118
status_code=404,
84119
phrase="Not Found",
85120
headers={"From": "sip:bob@biloxi.com"},
86121
)
87-
assert Message.parse(bytes(response)) == response
88-
89-
def test_parse__skips_header_line_without_colon(self):
90-
"""Skip header lines that contain no colon separator."""
91-
data = b"REGISTER sip:example.com SIP/2.0\r\nInvalidHeaderLine\r\n\r\n"
92-
result = Message.parse(data)
93-
assert isinstance(result, Request)
94-
assert "InvalidHeaderLine" not in result.headers
95-
96-
def test_parse__from_header__is_caller_id(self):
97-
"""From header is parsed as a CallerID instance."""
98-
data = (
99-
b"INVITE sip:bob@biloxi.com SIP/2.0\r\nFrom: sip:alice@atlanta.com\r\n\r\n"
100-
)
101-
result = Message.parse(data)
102-
assert isinstance(result.headers["From"], CallerID)
103-
assert result.headers["From"] == "sip:alice@atlanta.com"
104-
105-
def test_parse__to_header__is_caller_id(self):
106-
"""To header is parsed as a CallerID instance."""
107-
data = b"INVITE sip:bob@biloxi.com SIP/2.0\r\nTo: sip:bob@biloxi.com\r\n\r\n"
108-
result = Message.parse(data)
109-
assert isinstance(result.headers["To"], CallerID)
122+
assert messages.Message.parse(bytes(response)) == response
110123

111124
def test_parse__from_header__roundtrip_preserves_raw_value(self):
112125
"""str(CallerID) equals the original header string, so serialization is unchanged."""
@@ -115,17 +128,17 @@ def test_parse__from_header__roundtrip_preserves_raw_value(self):
115128
b'From: "08001234567" <sip:08001234567@telefonica.de>;tag=abc\r\n'
116129
b"\r\n"
117130
)
118-
result = Message.parse(data)
131+
result = messages.Message.parse(data)
119132
assert bytes(result) == data
120133

121134
def test_parse__raises_value_error_on_invalid_first_line(self):
122135
"""Raise ValueError when the first line cannot be parsed as a request."""
123-
with pytest.raises(ValueError, match="Invalid SIP message"):
124-
Message.parse(b"TOOSHORT\r\n\r\n")
136+
with pytest.raises(ValueError, match="Invalid header"):
137+
messages.Message.parse(b"TOOSHORT\r\n\r\n")
125138

126139
def test___str____returns_decoded_bytes(self):
127140
"""Return the string representation of a request as decoded bytes."""
128-
request = Request(
141+
request = messages.Request(
129142
method="REGISTER",
130143
uri="sip:registrar.biloxi.com",
131144
headers={"From": "sip:bob@biloxi.com"},
@@ -139,7 +152,7 @@ def test_branch__extracts_via_branch_parameter(self):
139152
b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n"
140153
b"\r\n"
141154
)
142-
request = Message.parse(data)
155+
request = messages.Message.parse(data)
143156
assert request.branch == "z9hG4bKabc"
144157

145158
def test_remote_tag__with_tag(self):
@@ -150,7 +163,7 @@ def test_remote_tag__with_tag(self):
150163
b"To: sip:bob@biloxi.com;tag=to-tag-1\r\n"
151164
b"\r\n"
152165
)
153-
request = Message.parse(data)
166+
request = messages.Message.parse(data)
154167
assert request.remote_tag == "to-tag-1"
155168

156169
def test_local_tag__with_tag(self):
@@ -161,7 +174,7 @@ def test_local_tag__with_tag(self):
161174
b"From: sip:alice@atlanta.com;tag=from-tag-1\r\n"
162175
b"\r\n"
163176
)
164-
request = Message.parse(data)
177+
request = messages.Message.parse(data)
165178
assert request.local_tag == "from-tag-1"
166179

167180
def test_sequence__returns_cseq_number(self):
@@ -172,14 +185,14 @@ def test_sequence__returns_cseq_number(self):
172185
b"CSeq: 42 INVITE\r\n"
173186
b"\r\n"
174187
)
175-
request = Message.parse(data)
188+
request = messages.Message.parse(data)
176189
assert request.sequence == 42
177190

178191

179192
class TestRequest:
180193
def test___bytes__(self):
181194
"""Serialize a SIP request to bytes."""
182-
request = Request(
195+
request = messages.Request(
183196
method="INVITE",
184197
uri="sip:bob@biloxi.com",
185198
headers={"Via": "SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds"},
@@ -193,7 +206,7 @@ def test___bytes__(self):
193206
def test___bytes____with_sdp_body(self):
194207
"""Serialize a SIP request with an SDP body to bytes."""
195208
sdp = SessionDescription()
196-
request = Request(
209+
request = messages.Request(
197210
method="INVITE",
198211
uri="sip:bob@biloxi.com",
199212
body=sdp,
@@ -204,7 +217,7 @@ def test___bytes____with_sdp_body(self):
204217

205218
def test_branch__with_branch(self):
206219
"""Branch returns the branch parameter from the Via header."""
207-
request = Request(
220+
request = messages.Request(
208221
method="INVITE",
209222
uri="sip:bob@biloxi.com",
210223
headers={"Via": "SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc123"},
@@ -213,12 +226,12 @@ def test_branch__with_branch(self):
213226

214227
def test_from_dialog__merges_dialog_headers(self):
215228
"""Merge the provided headers with the dialog's headers."""
216-
dialog = Dialog(
229+
dialog = messages.Dialog(
217230
uac=SipUri.parse("sips:alice@example.com"),
218231
local_tag="local-tag",
219232
remote_tag="remote-tag",
220233
)
221-
request = Request.from_dialog(
234+
request = messages.Request.from_dialog(
222235
dialog=dialog,
223236
headers={"Via": "SIP/2.0/TLS example.com;branch=z9hG4bK123"},
224237
method="REGISTER",
@@ -232,7 +245,7 @@ def test_from_dialog__merges_dialog_headers(self):
232245
class TestResponse:
233246
def test___bytes__(self):
234247
"""Serialize a SIP response to bytes."""
235-
response = Response(
248+
response = messages.Response(
236249
status_code=200,
237250
phrase="OK",
238251
headers={"Via": "SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bK776asdhds"},
@@ -246,18 +259,18 @@ def test___bytes__(self):
246259
def test___bytes____with_sdp_body(self):
247260
"""Serialize a SIP response with an SDP body to bytes."""
248261
sdp = SessionDescription()
249-
response = Response(status_code=200, phrase="OK", body=sdp)
262+
response = messages.Response(status_code=200, phrase="OK", body=sdp)
250263
serialized = bytes(response)
251264
assert b"Content-Length:" in serialized
252265
assert b"v=0" in serialized
253266

254267
def test___bytes____with_sdp_body__auto_content_length(self):
255268
"""Auto-calculate Content-Length when SDP body is present and header is not set."""
256269
sdp = SessionDescription()
257-
response = Response(status_code=200, phrase="OK", body=sdp)
270+
response = messages.Response(status_code=200, phrase="OK", body=sdp)
258271
serialized = bytes(response)
259272
assert b"Content-Length:" in serialized
260-
parsed = Message.parse(serialized)
273+
parsed = messages.Message.parse(serialized)
261274
assert parsed.body is None
262275

263276
def test_from_request__with_dialog_remote_tag(self):
@@ -271,12 +284,12 @@ def test_from_request__with_dialog_remote_tag(self):
271284
b"CSeq: 1 INVITE\r\n"
272285
b"\r\n"
273286
)
274-
request = Message.parse(data)
275-
dialog = Dialog(
287+
request = messages.Message.parse(data)
288+
dialog = messages.Dialog(
276289
uac=SipUri.parse("sip:alice@atlanta.com"),
277290
remote_tag="server-tag",
278291
)
279-
response = Response.from_request(
292+
response = messages.Response.from_request(
280293
request, dialog=dialog, status_code=200, phrase="OK"
281294
)
282295
assert "server-tag" in str(response.headers["To"])
@@ -292,39 +305,39 @@ def test_from_request__without_dialog(self):
292305
b"CSeq: 1 OPTIONS\r\n"
293306
b"\r\n"
294307
)
295-
request = Message.parse(data)
296-
response = Response.from_request(request, status_code=200, phrase="OK")
308+
request = messages.Message.parse(data)
309+
response = messages.Response.from_request(request, status_code=200, phrase="OK")
297310
assert response.headers["To"] == request.headers["To"]
298311

299312

300313
class TestDialog:
301314
def test_from_header__contains_local_tag(self):
302315
"""from_header includes the local_tag parameter."""
303-
dialog = Dialog(
316+
dialog = messages.Dialog(
304317
uac=SipUri.parse("sips:alice@example.com"),
305318
local_tag="my-local-tag",
306319
)
307320
assert "my-local-tag" in dialog.from_header
308321

309322
def test_to_header__without_remote_tag(self):
310323
"""to_header omits the tag parameter when remote_tag is None."""
311-
dialog = Dialog(
324+
dialog = messages.Dialog(
312325
uac=SipUri.parse("sip:bob@biloxi.com:5060"),
313326
remote_tag=None,
314327
)
315328
assert ";tag=" not in dialog.to_header
316329

317330
def test_to_header__with_remote_tag(self):
318331
"""to_header includes the remote_tag parameter."""
319-
dialog = Dialog(
332+
dialog = messages.Dialog(
320333
uac=SipUri.parse("sip:bob@biloxi.com:5060"),
321334
remote_tag="their-tag",
322335
)
323336
assert "their-tag" in dialog.to_header
324337

325338
def test_headers__returns_required_keys(self):
326339
"""Headers property returns From, To, and Call-ID keys."""
327-
dialog = Dialog(uac=SipUri.parse("sips:alice@example.com"))
340+
dialog = messages.Dialog(uac=SipUri.parse("sips:alice@example.com"))
328341
headers = dialog.headers
329342
assert "From" in headers
330343
assert "To" in headers
@@ -341,8 +354,8 @@ def test_from_request__extracts_call_id_and_tags(self):
341354
b"CSeq: 1 INVITE\r\n"
342355
b"\r\n"
343356
)
344-
request = Message.parse(data)
345-
dialog = Dialog.from_request(request)
357+
request = messages.Message.parse(data)
358+
dialog = messages.Dialog.from_request(request)
346359
assert dialog.call_id == "call-99@atlanta.com"
347360
assert dialog.local_tag == "from-tag-99"
348361
assert dialog.remote_tag is not None

tests/sip/test_protocol.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,3 +857,8 @@ def test_connection_lost__no_keepalive_task_without_sip_fixture(self, rtp):
857857
session.keepalive_task = None
858858
session.connection_lost(None)
859859
assert session.transport is None
860+
861+
def test_on_registered__is_noop(self, rtp, fake_transport):
862+
"""on_registered base implementation does nothing and returns None."""
863+
session = self._make_session(rtp, fake_transport)
864+
assert session.on_registered() is None

0 commit comments

Comments
 (0)